From 564f989cf32b75bea3555e673080b0658d068a49 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 5 Aug 2026 16:51:52 -0300 Subject: [PATCH] fix: eliminate unread badge dispatch storm that crashed TabBar rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On webapp boot the server fires one unread-changed-by-subscription event per room. Each event dispatched a badge update to the root window's Redux store, the servers reducer minted a new array even for no-op patches, and every server-subscribed component re-rendered per dispatch — with unread rooms present at startup, React aborted TabBar with 'Maximum update depth exceeded' on every launch. - injected.ts: coalesce badge recomputes into a 100ms trailing-edge timer and skip setBadge entirely when the resolved value did not change - preload badge.ts: skip dispatching consecutive identical badge values (also covers the pre-7.8.0 Session autorun path) - servers reducers: upsert/update now preserve object and array identity when a patch would not change any field, so no-op actions no longer re-render every consumer - bootWatchdog: arm the boot deadline on the first committed navigation instead of on attach — webviews that legitimately never navigate (lazy or error panes) produced false boot-deadline-exceeded reports --- src/injected.ts | 40 +++++++++++++++++++++++++++++++----- src/servers/bootWatchdog.ts | 4 +++- src/servers/preload/badge.ts | 12 +++++++++++ src/servers/reducers.ts | 24 ++++++++++++++++------ 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/injected.ts b/src/injected.ts index 258709588b..c23a83eeb7 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -527,6 +527,19 @@ const start = async () => { // only sees rooms that changed after the listener attached, so it can // undercount; we prefer the aggregate for the numeric total and use the // map solely to rebuild the alert-only "•" indicator. + // Server boot floods this path: the webapp fires one + // `unread-changed-by-subscription` event per room when it starts, and + // dispatching a badge update for each one storms the root window's + // Redux store hard enough that React aborts with "Maximum update depth + // exceeded". Recomputes are therefore coalesced into a single + // trailing-edge call, and the dispatch is skipped entirely when the + // resolved badge value did not change. + const BADGE_COALESCE_MS = 100; + let resolveBadgeTimer: ReturnType | null = null; + let pendingAggregateCount: number | undefined; + let lastSentBadge: number | '•' | undefined; + let hasSentBadge = false; + const resolveBadge = (aggregateCount?: number): void => { let unreadCount = 0; let alertIndicator: '•' | undefined; @@ -557,11 +570,28 @@ const start = async () => { ? aggregateCount : unreadCount; - if (total > 0) { - window.RocketChatDesktop.setBadge(total); + const badge = total > 0 ? total : alertIndicator ?? 0; + if (hasSentBadge && badge === lastSentBadge) { + return; + } + hasSentBadge = true; + lastSentBadge = badge; + window.RocketChatDesktop.setBadge(badge); + }; + + const scheduleResolveBadge = (aggregateCount?: number): void => { + if (aggregateCount !== undefined) { + pendingAggregateCount = aggregateCount; + } + if (resolveBadgeTimer !== null) { return; } - window.RocketChatDesktop.setBadge(alertIndicator ?? 0); + resolveBadgeTimer = setTimeout(() => { + resolveBadgeTimer = null; + const aggregate = pendingAggregateCount; + pendingAggregateCount = undefined; + resolveBadge(aggregate); + }, BADGE_COALESCE_MS); }; window.addEventListener('unread-changed-by-subscription', (event) => { @@ -581,7 +611,7 @@ const start = async () => { alert: subscription.alert, unreadAlert: subscription.unreadAlert, }); - resolveBadge(); + scheduleResolveBadge(); }); window.addEventListener('unread-changed', (event) => { @@ -590,7 +620,7 @@ const start = async () => { typeof detail === 'number' && Number.isFinite(detail) ? detail : undefined; - resolveBadge(aggregateCount); + scheduleResolveBadge(aggregateCount); }); setupFlags.unreadChangedEvent = true; diff --git a/src/servers/bootWatchdog.ts b/src/servers/bootWatchdog.ts index 47948f4907..0bc613df8d 100644 --- a/src/servers/bootWatchdog.ts +++ b/src/servers/bootWatchdog.ts @@ -209,9 +209,11 @@ export const attachBootWatchdog = ( booted: false, reportedForCurrentLoad: false, }; + // The deadline is armed by the first committed navigation (did-navigate), + // not here: webviews can attach and legitimately never navigate (lazy or + // error-view panes), and reporting those is pure noise. watchStates.set(serverUrl, state); record(state, 'attached'); - startDeadline(state); webContents.on('console-message', (event) => { const message = String(event.message ?? '').slice(0, MESSAGE_LENGTH_LIMIT); diff --git a/src/servers/preload/badge.ts b/src/servers/preload/badge.ts index 181ccf6b7d..60ceabcf0e 100644 --- a/src/servers/preload/badge.ts +++ b/src/servers/preload/badge.ts @@ -3,7 +3,19 @@ import { WEBVIEW_UNREAD_CHANGED } from '../../ui/actions'; import type { Server } from '../common'; import { getServerUrl } from './urls'; +let hasDispatched = false; +let lastBadge: Server['badge']; + export const setBadge = (badge: Server['badge']): void => { + // The pre-7.8.0 Session autorun and the unread event listeners can both + // re-emit unchanged values in rapid succession; a no-op dispatch still + // re-renders every server-subscribed component in the root window. + if (hasDispatched && Object.is(badge, lastBadge)) { + return; + } + hasDispatched = true; + lastBadge = badge; + dispatch({ type: WEBVIEW_UNREAD_CHANGED, payload: { diff --git a/src/servers/reducers.ts b/src/servers/reducers.ts index 86d9edc13f..2a22ce119d 100644 --- a/src/servers/reducers.ts +++ b/src/servers/reducers.ts @@ -77,6 +77,16 @@ type ServersActionTypes = | ActionOf | ActionOf; +// Returns the original object (preserving identity) when the patch would not +// change any field — a new array identity here re-renders every +// server-subscribed component, so no-op actions must not mint one. +const patchServer = (server: Server, patch: Server): Server => { + const changed = Object.entries(patch).some( + ([key, value]) => !Object.is(server[key as keyof Server], value) + ); + return changed ? { ...server, ...patch } : server; +}; + const upsert = (state: Server[], server: Server): Server[] => { const index = state.findIndex(({ url }) => url === server.url); @@ -84,9 +94,10 @@ const upsert = (state: Server[], server: Server): Server[] => { return [...state, server]; } - return state.map((_server, i) => - i === index ? { ..._server, ...server } : _server - ); + const patched = patchServer(state[index], server); + return patched === state[index] + ? state + : state.map((_server, i) => (i === index ? patched : _server)); }; const update = (state: Server[], server: Server): Server[] => { @@ -96,9 +107,10 @@ const update = (state: Server[], server: Server): Server[] => { return state; } - return state.map((_server, i) => - i === index ? { ..._server, ...server } : _server - ); + const patched = patchServer(state[index], server); + return patched === state[index] + ? state + : state.map((_server, i) => (i === index ? patched : _server)); }; export const servers: Reducer = (