mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
perf(app): cut idle renderer CPU and unbounded memory growth (#1724)
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user