From d2995acdfb150fd398936635c53f597e0a36d3bf Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 13 Dec 2021 13:56:16 +0100 Subject: [PATCH] fix: Add missing ngZone reenters --- .../Angular/concepts/change-detection.mdx | 77 ++++++ docusaurus/docs/Angular/services/channel.mdx | 10 +- .../docs/Angular/services/chat-client.mdx | 10 +- .../channel-preview.component.ts | 42 ++-- .../src/lib/channel.service.ts | 235 +++++++++--------- .../src/lib/chat-client.service.spec.ts | 21 +- .../src/lib/chat-client.service.ts | 52 ++-- .../src/lib/mocks/index.ts | 14 +- 8 files changed, 278 insertions(+), 183 deletions(-) create mode 100644 docusaurus/docs/Angular/concepts/change-detection.mdx diff --git a/docusaurus/docs/Angular/concepts/change-detection.mdx b/docusaurus/docs/Angular/concepts/change-detection.mdx new file mode 100644 index 00000000..944734e4 --- /dev/null +++ b/docusaurus/docs/Angular/concepts/change-detection.mdx @@ -0,0 +1,77 @@ +--- +id: change-detection +sidebar_position: 4 +title: Change detection +--- + +For performance reasons, the Stream chat WebSocket connection is opened outside of the [Angular change detection zone](https://angular.io/guide/zone). This means that when we react to WebSocket events, Angular won't update the UI in response to these events. Furthermore, if a new component is created reacting to a WebSocket event (for example, if we receive a new message, and a new message component is created to display the new message), the new component will operate outside of the Angular change detection zone. To solve this problem, we need to reenter Angular's change detection zone. + +## Reentering Angular's change detection zone + +You can reenter Angular's change detection zone with the `run` method of the `NgZone` service. For example if you want to display a notification when a user is added to a channel, you can watch for the `notification.added_to_channel` event and return to the zone when that event is received: + +```typescript +import { Component, NgZone, OnInit } from "@angular/core"; +import { filter } from "rxjs/operators"; +import { ChatClientService, NotificationService } from "stream-chat-angular"; + +@Component({ + selector: "app-root", + templateUrl: "./app.component.html", + styleUrls: ["./app.component.scss"], +}) +export class AppComponent implements OnInit { + constructor( + private chatService: ChatClientService, + private notificationService: NotificationService, + private ngZone: NgZone + ) {} + + ngOnInit(): void { + this.chatService.notification$ + .pipe(filter((n) => n.eventType === "notification.added_to_channel")) + .subscribe((notification) => { + // reenter Angular's change detection zone + this.ngZone.run(() => { + this.notificationService.addTemporaryNotification( + `You've been added to the ${notification.event.channel?.name} channel`, + "success" + ); + }); + }); + } +} +``` + +If you were to display the notification without reentering Angular's zone, the `addTemporaryNotification` would run outside of Angular's change detection zone, and the notification wouldn't disappear after the 5-second timeout. + +## When necessary to reenter the zone + +You need to reenter Angular's change detection zone when + +- you subscribe to events using the [`notification$`](../services/chat-client.mdx/#notification) Observable of the `ChatClientService` +- you subscribe to channel events + +For example the [`ChannelPreview`](../components/channel-preview.mdx) component needs to subscribe to the `message.read` channel events to know if the channel has unread messages and reenter Angular's zone when an event is received: + +```typescript +this.channel.on("message.read", () => + this.ngZone.run(() => { + this.isUnread = !!this.channel.countUnread() && this.canSendReadEvents; + }) +); +``` + +## When unnecessary to reenter the zone + +You **don't** need to reenter the zone when + +- you use the SDK's default components in your UI and don't watch for additional events +- when you [override the default channel list behavior](../services/channel.mdx/#channels) +- when you subscribe to the [`connectionState$`](../services/chat-client.mdx/#connectionstate) Observable of the `ChatClientService` + +If you are unsure whether or not you are in Angular's zone, you can use the following function call to check: + +```typescript +NgZone.isInAngularZone(); +``` diff --git a/docusaurus/docs/Angular/services/channel.mdx b/docusaurus/docs/Angular/services/channel.mdx index 44dc318a..9247b4a5 100644 --- a/docusaurus/docs/Angular/services/channel.mdx +++ b/docusaurus/docs/Angular/services/channel.mdx @@ -14,7 +14,13 @@ Queries the channels with the given filters, sorts and options. More info about ## channels$ -Emits the currently loaded and [watched](https://getstream.io/chat/docs/javascript/watch_channel/?language=javascript) channel list. Apart from pagination, the channel list is also updated on the following events: +Emits the currently loaded and [watched](https://getstream.io/chat/docs/javascript/watch_channel/?language=javascript) channel list. + +:::important +If you want to subscribe to channel events, you need to manually reenter Angular's change detection zone, our [Change detection guide](../concepts/change-detection.mdx) explains this in detail. +::: + +Apart from pagination, the channel list is also updated on the following events: | Event type | Default behavior | Custom handler to override | | ----------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- | @@ -37,7 +43,7 @@ Our platform documentation covers the topic of [channel events](https://getstrea Emits the currently active channel. :::important -Please note that for performance reaasons the client is connected [outside of the NgZone](https://angular.io/guide/zone#ngzone-1), if you want to subscribe to [notification or channel events](https://getstream.io/chat/docs/javascript/event_object/?language=javascript), you will need to [reenter the NgZone](https://angular.io/guide/zone#ngzone-run-and-runoutsideofangular) or call change detection manually (you can use the [`ChangeDetectorRef`](https://angular.io/api/core/ChangeDetectorRef) or the [`ApplicationRef`](https://angular.io/api/core/ApplicationRef) for that). +If you want to subscribe to channel events, you need to manually reenter Angular's change detection zone, our [Change detection guide](../concepts/change-detection.mdx) explains this in detail. ::: ## setAsActiveChannel diff --git a/docusaurus/docs/Angular/services/chat-client.mdx b/docusaurus/docs/Angular/services/chat-client.mdx index 15b58e9b..4a7bf5e1 100644 --- a/docusaurus/docs/Angular/services/chat-client.mdx +++ b/docusaurus/docs/Angular/services/chat-client.mdx @@ -12,17 +12,17 @@ The `ChatClient` service connects the user to the Stream chat. The [StreamChat client](https://github.com/GetStream/stream-chat-js/blob/master/src/client.ts) instance. In general you shouldn't need to access the client, but it's there if you want to use it. -:::important -Please note that for performance reaasons the client is connected [outside of the NgZone](https://angular.io/guide/zone#ngzone-1), if you want to subscribe to [notification or channel events](https://getstream.io/chat/docs/javascript/event_object/?language=javascript), you will need to [reenter the NgZone](https://angular.io/guide/zone#ngzone-run-and-runoutsideofangular) or call change detection manually (you can use the [`ChangeDetectorRef`](https://angular.io/api/core/ChangeDetectorRef) or the [`ApplicationRef`](https://angular.io/api/core/ApplicationRef) for that). -::: - ## init Creates a [`StreamChat`](https://github.com/GetStream/stream-chat-js/blob/668b3e5521339f4e14fc657834531b4c8bf8176b/src/client.ts#L124) instance using the provided `apiKey`, and connects a user with the given `userId` and `userToken`. More info about [connecting users](https://getstream.io/chat/docs/javascript/init_and_users/?language=javascript) can be found in the platform documentation. ## notification$ -Emits [`Notification`](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angular/src/lib/chat-client.service.ts) events, the list of [supported events](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angular/src/lib/chat-client.service.ts) can be found on GitHub. The platform documentation covers [events in detail](https://getstream.io/chat/docs/javascript/event_object/?language=javascript). +Emits [`Notification`](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angular/src/lib/chat-client.service.ts) events. The platform documentation covers [the list of client and notification events](https://getstream.io/chat/docs/javascript/event_object/?language=javascript). + +:::important +For performance reasons this Observable operates outside of the Angular change detection zone. If you subscribe to it, you need to manually reenter Angular's change detection zone, our [Change detection guide](../concepts/change-detection.mdx) explains this in detail. +::: ## connectionState$ diff --git a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts index 8160060f..e50b53ed 100644 --- a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts +++ b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input, NgZone, OnDestroy, OnInit } from '@angular/core'; import { Subscription } from 'rxjs'; import { Channel, @@ -21,7 +21,7 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { private subscriptions: (Subscription | { unsubscribe: () => void })[] = []; private canSendReadEvents = true; - constructor(private channelService: ChannelService) {} + constructor(private channelService: ChannelService, private ngZone: NgZone) {} ngOnInit(): void { this.subscriptions.push( @@ -51,11 +51,11 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { this.channel!.on('channel.truncated', this.handleMessageEvent.bind(this)) ); this.subscriptions.push( - this.channel!.on( - 'message.read', - () => - (this.isUnread = - !!this.channel!.countUnread() && this.canSendReadEvents) + this.channel!.on('message.read', () => + this.ngZone.run(() => { + this.isUnread = + !!this.channel!.countUnread() && this.canSendReadEvents; + }) ) ); } @@ -81,19 +81,21 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { } private handleMessageEvent(event: Event) { - if (this.channel?.state.messages.length === 0) { - this.latestMessage = 'Nothing yet...'; - return; - } - if ( - !event.message || - this.channel?.state.messages[this.channel?.state.messages.length - 1] - .id !== event.message.id - ) { - return; - } - this.setLatestMessage(event.message); - this.isUnread = !!this.channel.countUnread() && this.canSendReadEvents; + this.ngZone.run(() => { + if (this.channel?.state.messages.length === 0) { + this.latestMessage = 'Nothing yet...'; + return; + } + if ( + !event.message || + this.channel?.state.messages[this.channel?.state.messages.length - 1] + .id !== event.message.id + ) { + return; + } + this.setLatestMessage(event.message); + this.isUnread = !!this.channel.countUnread() && this.canSendReadEvents; + }); } private setLatestMessage(message?: FormatMessageResponse | MessageResponse) { diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index 602fc58e..a135070e 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -1,4 +1,4 @@ -import { ApplicationRef, Injectable, NgZone } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { @@ -100,7 +100,6 @@ export class ChannelService { constructor( private chatClientService: ChatClientService, - private appRef: ApplicationRef, private ngZone: NgZone ) { this.channels$ = this.channelsSubject.asObservable(); @@ -331,45 +330,49 @@ export class ChannelService { private async handleNotification(notification: Notification) { switch (notification.eventType) { case 'notification.message_new': { - if (this.customNewMessageNotificationHandler) { - this.customNewMessageNotificationHandler( - notification, - this.channelListSetter - ); - } else { - await this.handleNewMessageNotification(notification); - } + await this.ngZone.run(async () => { + if (this.customNewMessageNotificationHandler) { + this.customNewMessageNotificationHandler( + notification, + this.channelListSetter + ); + } else { + await this.handleNewMessageNotification(notification); + } + }); break; } case 'notification.added_to_channel': { - if (this.customAddedToChannelNotificationHandler) { - this.customAddedToChannelNotificationHandler( - notification, - this.channelListSetter - ); - } else { - await this.handleAddedToChannelNotification(notification); - } + await this.ngZone.run(async () => { + if (this.customAddedToChannelNotificationHandler) { + this.customAddedToChannelNotificationHandler( + notification, + this.channelListSetter + ); + } else { + await this.handleAddedToChannelNotification(notification); + } + }); break; } case 'notification.removed_from_channel': { - if (this.customRemovedFromChannelNotificationHandler) { - this.customRemovedFromChannelNotificationHandler( - notification, - this.channelListSetter - ); - } else { - this.handleRemovedFromChannelNotification(notification); - } + this.ngZone.run(() => { + if (this.customRemovedFromChannelNotificationHandler) { + this.customRemovedFromChannelNotificationHandler( + notification, + this.channelListSetter + ); + } else { + this.handleRemovedFromChannelNotification(notification); + } + }); } } } private handleRemovedFromChannelNotification(notification: Notification) { - this.ngZone.run(() => { - const channelIdToBeRemoved = notification.event.channel!.cid; - this.removeFromChannelList(channelIdToBeRemoved); - }); + const channelIdToBeRemoved = notification.event.channel!.cid; + this.removeFromChannelList(channelIdToBeRemoved); } private async handleNewMessageNotification(notification: Notification) { @@ -387,12 +390,10 @@ export class ChannelService { ); await channel.watch(); this.watchForChannelEvents(channel); - this.ngZone.run(() => { - this.channelsSubject.next([ - channel, - ...(this.channelsSubject.getValue() || []), - ]); - }); + this.channelsSubject.next([ + channel, + ...(this.channelsSubject.getValue() || []), + ]); } private removeFromChannelList(cid: string) { @@ -439,18 +440,20 @@ export class ChannelService { ); this.activeChannelSubscriptions.push( channel.on('message.read', (e) => { - let latestMessage!: StreamMessage; - this.activeChannelMessages$.pipe(first()).subscribe((messages) => { - latestMessage = messages[messages.length - 1]; - }); - if (!latestMessage || !e.user) { - return; - } - latestMessage.readBy = getReadBy(latestMessage, channel); + this.ngZone.run(() => { + let latestMessage!: StreamMessage; + this.activeChannelMessages$.pipe(first()).subscribe((messages) => { + latestMessage = messages[messages.length - 1]; + }); + if (!latestMessage || !e.user) { + return; + } + latestMessage.readBy = getReadBy(latestMessage, channel); - this.activeChannelMessagesSubject.next( - this.activeChannelMessagesSubject.getValue() - ); + this.activeChannelMessagesSubject.next( + this.activeChannelMessagesSubject.getValue() + ); + }); }) ); } @@ -544,90 +547,96 @@ export class ChannelService { channel.on((event: Event) => { switch (event.type) { case 'message.new': { - if (this.customNewMessageHandler) { - this.customNewMessageHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter - ); - } else { - this.handleNewMessage(event, channel); - } + this.ngZone.run(() => { + if (this.customNewMessageHandler) { + this.customNewMessageHandler( + event, + channel, + this.channelListSetter, + this.messageListSetter + ); + } else { + this.handleNewMessage(event, channel); + } + }); break; } case 'channel.hidden': { - if (this.customChannelHiddenHandler) { - this.customChannelHiddenHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter - ); - } else { - this.handleChannelHidden(event); - } - this.appRef.tick(); + this.ngZone.run(() => { + if (this.customChannelHiddenHandler) { + this.customChannelHiddenHandler( + event, + channel, + this.channelListSetter, + this.messageListSetter + ); + } else { + this.handleChannelHidden(event); + } + }); break; } case 'channel.deleted': { - if (this.customChannelDeletedHandler) { - this.customChannelDeletedHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter - ); - } else { - this.handleChannelDeleted(event); - } - this.appRef.tick(); + this.ngZone.run(() => { + if (this.customChannelDeletedHandler) { + this.customChannelDeletedHandler( + event, + channel, + this.channelListSetter, + this.messageListSetter + ); + } else { + this.handleChannelDeleted(event); + } + }); break; } case 'channel.visible': { - if (this.customChannelVisibleHandler) { - this.customChannelVisibleHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter - ); - } else { - this.handleChannelVisible(event, channel); - } - this.appRef.tick(); + this.ngZone.run(() => { + if (this.customChannelVisibleHandler) { + this.customChannelVisibleHandler( + event, + channel, + this.channelListSetter, + this.messageListSetter + ); + } else { + this.handleChannelVisible(event, channel); + } + }); break; } case 'channel.updated': { - if (this.customChannelUpdatedHandler) { - this.customChannelUpdatedHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter - ); - } else { - this.handleChannelUpdate(event); - } - this.appRef.tick(); + this.ngZone.run(() => { + if (this.customChannelUpdatedHandler) { + this.customChannelUpdatedHandler( + event, + channel, + this.channelListSetter, + this.messageListSetter + ); + } else { + this.handleChannelUpdate(event); + } + }); break; } case 'channel.truncated': { - if (this.customChannelTruncatedHandler) { - this.customChannelTruncatedHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter - ); - } else { - this.handleChannelTruncate(event); - } - this.appRef.tick(); + this.ngZone.run(() => { + if (this.customChannelTruncatedHandler) { + this.customChannelTruncatedHandler( + event, + channel, + this.channelListSetter, + this.messageListSetter + ); + } else { + this.handleChannelTruncate(event); + } + }); break; } } - setTimeout(() => this.appRef.tick(), 0); }); } diff --git a/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts b/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts index 5272303a..cfc0e7dc 100644 --- a/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts @@ -59,8 +59,11 @@ describe('ChatClientService', () => { it('should watch for added to channel events', () => { const spy = jasmine.createSpy(); service.notification$.subscribe(spy); - const event = { id: 'mockevent' } as any as Event; - mockChatClient.handleEvent('notification.added_to_channel', event); + const event = { + id: 'mockevent', + type: 'notification.added_to_channel', + } as any as Event; + mockChatClient.handleEvent(event.type, event); expect(spy).toHaveBeenCalledWith({ eventType: 'notification.added_to_channel', @@ -71,8 +74,11 @@ describe('ChatClientService', () => { it('should watch for new message events', () => { const spy = jasmine.createSpy(); service.notification$.subscribe(spy); - const event = { id: 'mockevent' } as any as Event; - mockChatClient.handleEvent('notification.message_new', event); + const event = { + id: 'mockevent', + type: 'notification.message_new', + } as any as Event; + mockChatClient.handleEvent(event.type, event); expect(spy).toHaveBeenCalledWith({ eventType: 'notification.message_new', @@ -83,8 +89,11 @@ describe('ChatClientService', () => { it('should watch for removed from channel events', () => { const spy = jasmine.createSpy(); service.notification$.subscribe(spy); - const event = { id: 'mockevent' } as any as Event; - mockChatClient.handleEvent('notification.removed_from_channel', event); + const event = { + id: 'mockevent', + type: 'notification.removed_from_channel', + } as any as Event; + mockChatClient.handleEvent(event.type, event); expect(spy).toHaveBeenCalledWith({ eventType: 'notification.removed_from_channel', diff --git a/projects/stream-chat-angular/src/lib/chat-client.service.ts b/projects/stream-chat-angular/src/lib/chat-client.service.ts index 60b1027f..45fcaffc 100644 --- a/projects/stream-chat-angular/src/lib/chat-client.service.ts +++ b/projects/stream-chat-angular/src/lib/chat-client.service.ts @@ -1,14 +1,11 @@ -import { ApplicationRef, Injectable, NgZone } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; import { AppSettings, Event, StreamChat } from 'stream-chat'; import { version } from '../assets/version'; import { NotificationService } from './notification.service'; export type Notification = { - eventType: - | 'notification.added_to_channel' - | 'notification.message_new' - | 'notification.removed_from_channel'; + eventType: string; event: Event; }; @@ -28,7 +25,6 @@ export class ChatClientService { constructor( private ngZone: NgZone, - private appRef: ApplicationRef, private notificationService: NotificationService ) { this.notification$ = this.notificationSubject.asObservable(); @@ -43,43 +39,31 @@ export class ChatClientService { this.chatClient.setUserAgent( `stream-chat-angular-${version}-${this.chatClient.getUserAgent()}` ); + this.chatClient.getAppSettings; }); this.appSettingsSubject.next(undefined); - this.chatClient.on('notification.added_to_channel', (e) => { + this.chatClient.on((e) => { this.notificationSubject.next({ - eventType: 'notification.added_to_channel', + eventType: e.type, event: e, }); - this.appRef.tick(); - }); - this.chatClient.on('notification.message_new', (e) => { - this.notificationSubject.next({ - eventType: 'notification.message_new', - event: e, - }); - this.appRef.tick(); - }); - this.chatClient.on('notification.removed_from_channel', (e) => { - this.notificationSubject.next({ - eventType: 'notification.removed_from_channel', - event: e, - }); - this.appRef.tick(); }); let removeNotification: undefined | Function; this.chatClient.on('connection.changed', (e) => { - const isOnline = e.online; - if (isOnline) { - if (removeNotification) { - removeNotification(); + this.ngZone.run(() => { + const isOnline = e.online; + if (isOnline) { + if (removeNotification) { + removeNotification(); + } + } else { + removeNotification = + this.notificationService.addPermanentNotification( + 'streamChat.Connection failure, reconnecting now...' + ); } - } else { - removeNotification = this.notificationService.addPermanentNotification( - 'streamChat.Connection failure, reconnecting now...' - ); - } - this.connectionStateSubject.next(isOnline ? 'online' : 'offline'); - this.appRef.tick(); + this.connectionStateSubject.next(isOnline ? 'online' : 'offline'); + }); }); } diff --git a/projects/stream-chat-angular/src/lib/mocks/index.ts b/projects/stream-chat-angular/src/lib/mocks/index.ts index a7109996..57a80969 100644 --- a/projects/stream-chat-angular/src/lib/mocks/index.ts +++ b/projects/stream-chat-angular/src/lib/mocks/index.ts @@ -228,11 +228,19 @@ export const mockStreamChatClient = (): MockStreamChatClient => { }); /* eslint-enable jasmine/no-unsafe-spy */ const user = mockCurrentUser(); - const on = (name: EventTypes, handler: () => {}) => { - eventHandlers[name as string] = handler; + const on = (name: EventTypes | Function, handler: () => {}) => { + if (typeof name === 'string') { + eventHandlers[name as string] = handler; + } else { + eventHandlers['all'] = name; + } }; const handleEvent = (name: EventTypes, event: Event) => { - eventHandlers[name as string](event); + if (eventHandlers[name as string]) { + eventHandlers[name as string](event); + } else { + eventHandlers['all']({ ...event, type: name }); + } }; const getUserAgent = () => 'stream-chat-javascript-client-browser-2.2.2'; const appSettings$ = new Subject();