perf(app): cut idle renderer CPU and unbounded memory growth (#1724)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-18 23:13:42 +02:00
1 parent b4a5eb77a8
commit 5660972c71
13 files changed
+453 -345

No files matched your search

@@ -3,7 +3,7 @@
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {type RefObject, useEffect, useRef} from 'react';
import {type RefObject, useEffect} from 'react';
interface MessageListKeyboardNavigationOptions {
containerRef?: RefObject<ScrollerHandle | HTMLElement | null>;
@@ -77,15 +77,15 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
onEscape,
allowWhenInactive = false,
} = options;
const messageNodesCache = useRef<MessageNodesSnapshot>(EMPTY_MESSAGE_NODES_SNAPSHOT);
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
useEffect(() => {
if (!keyboardModeEnabled) return;
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
let observedRoot: ParentNode | null = null;
let observer: MutationObserver | null = null;
const invalidateMessageNodesCache = () => {
messageNodesCache.current = {
...messageNodesCache.current,
messageNodesCache = {
...messageNodesCache,
nodes: [],
indexById: new Map(),
ts: 0,
@@ -108,14 +108,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
};
const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
const now = Date.now();
const cache = messageNodesCache.current;
const cache = messageNodesCache;
const container = getScrollerNode(containerRef?.current ?? null);
if (!container && containerRef) {
messageNodesCache.current = {
messageNodesCache = {
...EMPTY_MESSAGE_NODES_SNAPSHOT,
ts: now,
};
return messageNodesCache.current;
return messageNodesCache;
}
const root = container ?? document;
const selector = getMessageSelector(channelId);
@@ -131,14 +131,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
indexById.set(messageId, i);
}
}
messageNodesCache.current = {
messageNodesCache = {
nodes,
indexById,
root,
selector,
ts: now,
};
return messageNodesCache.current;
return messageNodesCache;
};
const focusNode = (node: HTMLElement, messageId: string) => {
if (onFocusMessage) {
@@ -220,6 +220,9 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
observer?.disconnect();
observer = null;
observedRoot = null;
messageNodesCache = EMPTY_MESSAGE_NODES_SNAPSHOT;
};
}, [
keyboardModeEnabled,
@@ -39,6 +39,7 @@ type Listener = () => void;
const EMPTY_REACTIONS: ReadonlyArray<MessageReaction> = Object.freeze([]);
const EMPTY_USERS: ReadonlyArray<User> = Object.freeze([]);
const MAX_TRACKED_REACTION_REQUEST_KEYS = 512;
const createEmptyReactorEntry = (requestSerial = 0): ReactorEntry => ({
snapshot: createReactionUsersSnapshot(requestSerial),
});
@@ -68,6 +69,7 @@ export class MessageReactionsManager {
private reactors: Map<string, ReactorEntry> = new Map();
private _keysByMessage: Map<string, Set<string>> = new Map();
private retiredReactionRequests: Map<string, number> = new Map();
private inFlightReactionRequests: Map<string, Set<number>> = new Map();
private messageListeners: Map<string, Set<Listener>> = new Map();
private reactionListeners: Map<string, Set<Listener>> = new Map();
private transactionDepth = 0;
@@ -247,7 +249,9 @@ export class MessageReactionsManager {
handleFetchPending(messageId: string, emoji: ReactionEmoji): number {
const entry = this.getOrCreateReactorEntry(messageId, emoji);
this.commitReactorEvent(messageId, emoji, entry, {type: 'fetch.pending'});
return entry.snapshot.context.activeRequestId ?? entry.snapshot.context.requestSerial;
const requestId = entry.snapshot.context.activeRequestId ?? entry.snapshot.context.requestSerial;
this.trackInFlightRequest(getReactionKey(messageId, emoji), requestId);
return requestId;
}
handleFetchSuccess(
@@ -261,7 +265,9 @@ export class MessageReactionsManager {
nextAfter?: string | null,
): void {
const key = getReactionKey(messageId, emoji);
if (this.shouldIgnoreFetchResult(key, requestId)) return;
const ignored = this.shouldIgnoreFetchResult(key, requestId);
this.settleInFlightRequest(key, requestId);
if (ignored) return;
const entry = this.getOrCreateReactorEntry(messageId, emoji);
Users.cacheUsers(users.slice());
this.commitReactorEvent(messageId, emoji, entry, {
@@ -288,7 +294,9 @@ export class MessageReactionsManager {
nextAfter?: string | null,
): void {
const key = getReactionKey(messageId, emoji);
if (this.shouldIgnoreFetchResult(key, requestId)) return;
const ignored = this.shouldIgnoreFetchResult(key, requestId);
this.settleInFlightRequest(key, requestId);
if (ignored) return;
const entry = this.getOrCreateReactorEntry(messageId, emoji);
Users.cacheUsers(users.slice());
this.commitReactorEvent(messageId, emoji, entry, {
@@ -306,7 +314,9 @@ export class MessageReactionsManager {
handleFetchError(messageId: string, emoji: ReactionEmoji, requestId?: number): void {
const key = getReactionKey(messageId, emoji);
if (this.shouldIgnoreFetchResult(key, requestId)) return;
const ignored = this.shouldIgnoreFetchResult(key, requestId);
this.settleInFlightRequest(key, requestId);
if (ignored) return;
this.commitReactorEvent(messageId, emoji, this.getOrCreateReactorEntry(messageId, emoji), {
type: 'fetch.error',
requestId,
@@ -370,10 +380,37 @@ export class MessageReactionsManager {
}
private retireReactionEntry(key: string, entry = this.reactors.get(key)): void {
this.reactors.delete(key);
if (!this.inFlightReactionRequests.has(key)) {
this.retiredReactionRequests.delete(key);
return;
}
const requestSerial = entry?.snapshot.context.requestSerial ?? 0;
const nextRequestId = Math.max(this.retiredReactionRequests.get(key) ?? 0, requestSerial);
this.retiredReactionRequests.set(key, nextRequestId);
this.reactors.delete(key);
}
private trackInFlightRequest(key: string, requestId: number): void {
const requestIds = this.inFlightReactionRequests.get(key) ?? new Set<number>();
requestIds.add(requestId);
this.inFlightReactionRequests.delete(key);
this.inFlightReactionRequests.set(key, requestIds);
while (this.inFlightReactionRequests.size > MAX_TRACKED_REACTION_REQUEST_KEYS) {
const oldest = this.inFlightReactionRequests.keys().next();
if (oldest.done) break;
this.inFlightReactionRequests.delete(oldest.value);
this.retiredReactionRequests.delete(oldest.value);
}
}
private settleInFlightRequest(key: string, requestId?: number): void {
if (requestId == null) return;
const requestIds = this.inFlightReactionRequests.get(key);
if (!requestIds) return;
requestIds.delete(requestId);
if (requestIds.size > 0) return;
this.inFlightReactionRequests.delete(key);
this.retiredReactionRequests.delete(key);
}
private shouldIgnoreFetchResult(key: string, requestId?: number): boolean {
@@ -3,8 +3,15 @@
import {LRUCache} from 'lru-cache';
interface ImageCacheEntry {
image: HTMLImageElement;
src: string;
naturalWidth: number;
naturalHeight: number;
pin: HTMLImageElement;
}
export interface CachedImageSize {
width: number;
height: number;
}
interface PendingImageSubscriber {
@@ -31,9 +38,7 @@ const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
const getImageByteSize = (image: HTMLImageElement): number | null => {
const width = image.naturalWidth;
const height = image.naturalHeight;
const getDimensionByteSize = (width: number, height: number): number | null => {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
return null;
}
@@ -42,12 +47,23 @@ const getImageByteSize = (image: HTMLImageElement): number | null => {
return byteSize;
};
const getImageByteSize = (image: HTMLImageElement): number | null =>
getDimensionByteSize(image.naturalWidth, image.naturalHeight);
const estimateImageBytes = (entry: ImageCacheEntry): number => {
const byteSize = getImageByteSize(entry.image);
const byteSize = getDimensionByteSize(entry.naturalWidth, entry.naturalHeight);
if (byteSize == null) return MAX_CACHE_ENTRY_BYTES + FALLBACK_IMAGE_BYTES;
return byteSize;
};
const createImagePin = (src: string, image: HTMLImageElement): HTMLImageElement => {
if (image.parentNode == null) return image;
const pin = new Image();
pin.decoding = 'async';
pin.src = src;
return pin;
};
const imageCache = new LRUCache<string, ImageCacheEntry>({
max: MAX_CACHE_ENTRIES,
maxSize: MAX_CACHE_BYTES,
@@ -91,7 +107,7 @@ const isCached = (src: string | null): boolean => {
if (!acceptsImageSource(src)) return false;
const entry = imageCache.get(src);
if (!entry) return false;
if (entry.src === src && imageHasSource(entry.image, src) && isLoadedImage(entry.image)) return true;
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) return true;
imageCache.delete(src);
return false;
};
@@ -100,12 +116,13 @@ export function hasImage(src: string | null): boolean {
return isCached(src);
}
export function getImage(src: string | null): HTMLImageElement | undefined {
export function getImageSize(src: string | null): CachedImageSize | undefined {
if (!acceptsImageSource(src)) return undefined;
const entry = imageCache.get(src);
if (!entry) return undefined;
const image = entry.image;
if (entry.src === src && imageHasSource(image, src) && isLoadedImage(image)) return image;
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) {
return {width: entry.naturalWidth, height: entry.naturalHeight};
}
imageCache.delete(src);
return undefined;
}
@@ -118,7 +135,14 @@ export function rememberImage(src: string | null, image: HTMLImageElement): void
}
return;
}
if (isCacheableImage(image)) imageCache.set(src, {image, src});
if (isCacheableImage(image)) {
imageCache.set(src, {
src,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
pin: createImagePin(src, image),
});
}
if (!currentPendingLoad) return;
settlePendingImageLoad(src, currentPendingLoad, true, image);
}