Development notes

Messages page performance: proposed changes

Eight changes that make /messages usable sooner and cheaper to run. They are written against MessageContainerComponent at commit d2667a2ed4 of TopFansLite-WebFrontend. These changes are proposals. Nothing has been applied to the repository.

Gains are expected, not measured. Record a Chrome performance trace of /messages and a bundle analysis before and after, so each change can be checked.

Summary

Suggested order

  • Ship changes 1 and 3 first. They are small and remove the two largest fixed costs.
  • Ship change 4 with them. It is independent and low risk.
  • Ship change 2 on its own, because it changes how validation is cancelled.
  • Ship changes 5, 6, and 7 together as the lazy-loading pass, then measure the route chunk.
  • Ship change 8 last.

1. Remove the 1 second wait before DM validation

message-container.component.ts, getUserEmail()

The composer stays behind msgrightLoader until ChatUserValidation returns. That call is scheduled inside a fixed setTimeout(…, 1000). The block reads messageList[0], which is already filled when getUserEmail is called, so nothing in it needs the delay.

Change. Delete the timer and run its body directly. The body reads messageList[0] to find the other user, sets fetchedUser, then either clears the loader (lists and followers) or calls checkNewUserValidation.

  • Expected gain: at least 1 second before the composer appears on every conversation open.
  • Risk: low. Check that the thread does not flash an empty or half-scrolled state, since the old delay may have hidden that.

2. Start DM validation in parallel with message history

message-container.component.ts, fields, getUserChatMessage(), checkNewUserValidation(), getUserMsgDetails()

For a direct chat the other user is already known in getUserMsgDetails(userInfo), but validation only starts after previousMessages returns, because it derives the user from messageList[0]. Fire it right away instead. One trap: previousMessages and ChatUserValidation currently share userValidationCancel$, so starting validation would cancel the history request. Split the subject first.

Change. Split userValidationCancel$ into historyCancel$ and validationCancel$, and add validatedFor and validationPending fields. Call checkNewUserValidation(userInfo.gsi4pk) from getUserMsgDetails right after getUserChatMessage. In getUserEmail, skip the second call when the recipient already matches validatedFor and set the loader from validationPending. Reset both fields whenever a new conversation loads.

  • Expected gain: removes one sequential round trip, so the composer no longer waits for history. Apply after the timeout removal above.
  • Only the direct-chat path is changed. Lists, followers, and new-user starts still validate from getUserEmail.
  • Risk: medium. Test switching quickly between conversations, since stale validation must never apply to the wrong user. The reset in getUserChatMessage is what guards that.

3. Remove the change detection call after every view check

message-container.component.ts

ngAfterViewChecked calls detectChanges(), which re-runs change detection on the whole 2,300-line template after every check. Every keystroke, socket event, and timer tick pays for it twice. Other detectChanges calls in upload and recorder callbacks can stay.

Change. Delete the ngAfterViewChecked method.

  • Expected gain: roughly halves change detection work on this page.
  • Risk: medium. Something may rely on the extra pass. Walk through recording, emoji insert, tip popup, drag and drop, and share pickers. If one view lags, add a targeted markForCheck() there.
  • Follow-up, not in this diff: move to ChangeDetectionStrategy.OnPush after the component split.

4. Add trackBy to the message list

message-container.component.html line 587 and message-container.component.ts

The *ngFor over messageList has no trackBy. Angular compares by object identity, so any array replacement re-creates every bubble, including images and video and audio players. The existing trackItem returns the item itself, so it would not help here.

Change. Add trackBy: trackMessage to the messageList loop, and a trackMessage method that returns uinqueId, falling back to timestamp plus sender.

  • Expected gain: appending or confirming a message updates one bubble instead of rebuilding the list.
  • Risk: low. Confirm the key stays stable when a local message is confirmed by the server, and that two messages never share a timestamp and sender.

5. Load the emoji picker only when it opens

new emoji-picker-lazy.component.ts, message-container.component.html and .scss

emoji-mart is declared by PickerModule, which is an NgModule, so @defer cannot split it directly. Wrap it in a small standalone component and defer that. The wrapper must own the two emoji-mart style blocks now at scss lines 1061 and 1079, because Angular view encapsulation stops the container's styles reaching an element inside another component.

Change. Create a standalone EmojiPickerLazyComponent that imports PickerModule, re-emits emojiSelect, and uses display: contents on its host. Replace the emoji-mart element with @if (showEmojiPicker) wrapping a @defer block that renders the wrapper. Swap PickerModule for the wrapper in the container's imports, and move the two emoji-mart style blocks into the wrapper's SCSS.

  • Expected gain: the emoji picker code leaves the route chunk and loads on first open.
  • Risk: low to medium. Compare picker position and size before and after. The first open has a short delay, which @defer (prefetch on idle) can hide.

6. Load the video player only when a video plays

new message-video-player.component.ts, message-container.component.html lines 759 and 1178

vjs-ivs-player comes from NgIvsModule, which is not standalone, so the same wrapper approach applies. The player sits behind startVideo, so it is only needed after a tap. @cotufa82/ng-ivs is about 37 MB installed and pulls in video.js and the IVS player, so this is likely the largest chunk saving. The size is installed weight, not the measured bundle.

Change. Create a standalone MessageVideoPlayerComponent that imports NgIvsModule and VideoOption and takes a url input. Replace both vjs-ivs-player elements (lines 759 and 1178) with a @defer block that renders the wrapper, and swap NgIvsModule for the wrapper in the imports.

  • Repeat the template change at the second call site, line 1178.
  • Expected gain: video.js and the IVS player load on first play, not on page load.
  • Risk: low. Add a small placeholder or spinner inside @defer, since the first play now waits for the chunk.

7. Defer modals that are only opened on click

message-container.component.html lines 2223 to 2306, message-container.component.ts

The automated-message modal and the three share modals are standalone components, but they are created with the page. Wrap them in @defer (on idle) so they load after first render. The share modals are also creator-only. openMerchandiseShareModal dereferences its ViewChild without a guard, which would throw if a click beat the deferred load, so guard it.

Change. Wrap app-automated-message-modal in @defer (on idle). Wrap the three share modals in @if (isCreator) plus @defer (on idle). Add a ViewChild null guard to openMerchandiseShareModal.

  • Expected gain: four components and their dependencies, including the dynamic form builder and store pickers, leave the initial route chunk.
  • Risk: low. The automated modal is already called with ?.. Test creating, editing, and deleting an automated message right after page load.

8. Debounce the conversation list refresh on incoming messages

message-container.component.ts, receiveMessage()

Each incoming message calls getUserChatLogs(false, '') in both branches of receiveMessage. That refetches the whole list, and its success handler also fires another ChatUserValidation POST. A burst of messages produces a burst of both requests. Coalesce them.

Change. Add a refreshChatLogs$ subject, debounced by 500 ms and subscribed in ngAfterViewInit through subscriptionList. Replace both getUserChatLogs(false, '') calls in receiveMessage with refreshChatLogs$.next(), and import debounceTime.

  • Expected gain: one list fetch and one validation POST per burst of messages, instead of one per message.
  • Risk: low. The sidebar catches up up to 500 ms later. The open thread and header counter are updated separately and stay instant.

Regression checklist

  • Open a direct chat and confirm the composer appears without the old delay and shows the right free DM count.
  • Switch quickly between three conversations and confirm the tip and price controls match each user.
  • Open a message list, a followers send, and a new-user start. They must still validate correctly.
  • Send text, media, a voice note, and a paid message, and check upload progress and the leave-page guard.
  • Open the emoji picker, play a video, and open each share modal, both right after load and later.
  • Receive several messages in a row while viewing another conversation and confirm the list and header counter update.
  • Repeat as a fan, a creator, a delegate, and on a white-label site.

Not in these changes

  • Precompute the day label and time for each message once, instead of walking the array with moment() on every fetch.
  • Update a conversation row locally on receive instead of refetching the list.
  • Move to OnPush and signals as part of the component split.
  • Enable history paging. onUp() has its call commented out, so only the first page loads.