From 5660972c713b884567e38d4641be6eb0c54ddc67 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 18 Aug 2026 23:13:42 +0200 Subject: [PATCH] perf(app): cut idle renderer CPU and unbounded memory growth (#1724) --- .../app/hooks/useRovingFocusList.test.tsx | 209 ------------------ .../components/embeds/media/EmbedGifv.tsx | 19 +- .../components/embeds/media/EmbedVideo.tsx | 6 +- .../embeds/media/GifViewportGate.ts | 56 +++++ .../state/ChannelDisplayNameTracking.ts | 11 - .../src/features/channel/state/Channels.ts | 168 +++++++++++--- .../workers/AnimatedImageCropWorkerManager.ts | 111 ++++++++-- .../hooks/useMessageListKeyboardNavigation.ts | 21 +- .../messaging/state/MessageReactions.ts | 47 +++- .../messaging/utils/ImageCacheUtils.ts | 44 +++- .../features/platform/utils/LibFluxcore.ts | 86 ++++--- fluxer_app/src/features/user/models/User.ts | 1 + fluxer_app/src/features/user/state/Users.ts | 19 +- 13 files changed, 453 insertions(+), 345 deletions(-) delete mode 100644 fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx create mode 100644 fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts diff --git a/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx b/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx deleted file mode 100644 index f118f066f..000000000 --- a/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx +++ /dev/null @@ -1,209 +0,0 @@ -// @vitest-environment happy-dom -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList'; -import {act} from 'react'; -import {createRoot, type Root} from 'react-dom/client'; -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; - -(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; - -function RovingFocusProbe() { - const listRef = useRovingFocusList({ - autoFocusFirst: true, - focusableSelector: '[data-roving-focus="true"]', - manageTabIndex: true, - }); - return ( -
- - -
- Third -
-
- ); -} - -function ManualEntryRovingFocusProbe() { - const listRef = useRovingFocusList({ - focusableSelector: '[data-roving-focus="true"]', - manageTabIndex: true, - }); - return ( -
- - -
- ); -} - -const press = (target: Element, key: string): KeyboardEvent => { - const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true}); - act(() => { - target.dispatchEvent(event); - }); - return event; -}; - -const getItems = (container: HTMLElement): Array => - Array.from(container.querySelectorAll('[data-roving-focus="true"]')); - -describe('useRovingFocusList', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - beforeEach(() => { - container = document.createElement('div'); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root?.unmount(); - }); - root = null; - container?.remove(); - container = null; - }); - - it('moves focus with vertical arrow keys while keeping one tabbable item', () => { - act(() => { - root?.render(); - }); - const items = getItems(container!); - - expect(document.activeElement).toBe(items[0]); - expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]); - - const down = press(items[0], 'ArrowDown'); - expect(down.defaultPrevented).toBe(true); - expect(document.activeElement).toBe(items[1]); - expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]); - - press(items[1], 'ArrowDown'); - expect(document.activeElement).toBe(items[2]); - expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]); - - press(items[2], 'ArrowUp'); - expect(document.activeElement).toBe(items[1]); - expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]); - }); - - it('supports Home and End in a managed roving list', () => { - act(() => { - root?.render(); - }); - const items = getItems(container!); - - press(items[0], 'End'); - expect(document.activeElement).toBe(items[2]); - expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]); - - press(items[2], 'Home'); - expect(document.activeElement).toBe(items[0]); - expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]); - }); - - it('does not rewrite the roving-managed marker attribute on every navigation', () => { - act(() => { - root?.render(); - }); - const items = getItems(container!); - expect(items.every((item) => item.hasAttribute('data-roving-focus-managed'))).toBe(true); - - // Observe only the marker attribute, after it has already been applied once. - const observer = new MutationObserver(() => {}); - observer.observe(container!, { - subtree: true, - attributes: true, - attributeFilter: ['data-roving-focus-managed'], - }); - - press(items[0], 'ArrowDown'); - press(items[1], 'ArrowDown'); - press(items[2], 'ArrowUp'); - press(items[1], 'ArrowUp'); - press(items[0], 'End'); - press(items[2], 'Home'); - - const records = observer.takeRecords(); - observer.disconnect(); - - // The marker is write-once and only ever read via hasAttribute, so navigating - // must not touch it again. Rewriting it invalidates style for every list item - // on every keystroke, which is what drove idle style-recalc churn. - expect(records.length).toBe(0); - expect(items.every((item) => item.hasAttribute('data-roving-focus-managed'))).toBe(true); - }); - - it('can start on the container with no focused item before ArrowDown enters the list', () => { - act(() => { - root?.render( - , - ); - }); - const menu = container?.querySelector('[role="menu"]'); - const items = getItems(container!); - expect(menu).toBeInstanceOf(HTMLElement); - expect(document.activeElement).not.toBe(items[0]); - - act(() => { - menu?.focus(); - }); - expect(document.activeElement).toBe(menu); - expect(items.map((item) => item.tabIndex)).toEqual([0, -1]); - - const down = press(menu!, 'ArrowDown'); - expect(down.defaultPrevented).toBe(true); - expect(document.activeElement).toBe(items[0]); - expect(items.map((item) => item.tabIndex)).toEqual([0, -1]); - }); -}); diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx index 0802325b6..502d574e5 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx @@ -21,6 +21,7 @@ import { reduceGIFVRenderState, } from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine'; import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator'; +import {useGifViewportGate} from '@app/features/channel/components/embeds/media/GifViewportGate'; import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils'; import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer'; import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes'; @@ -985,9 +986,15 @@ export const EmbedGif: FC< const imgRef = useRef(null); const animatedImageRef = useRef(null); const freezeCanvasRef = useRef(null); - const {ref: visibilityRef, isNearViewport} = useNearViewport({ - disabled: !isMobile, + const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); + const { + ref: visibilityRef, + loadMedia: shouldLoadMedia, + animate: shouldAnimate, + } = useGifViewportGate({ + element: containerElement, rememberKey: proxyURL, + shouldBlur, }); const setContainerRef = useCallback( (node: HTMLDivElement | null) => { @@ -1018,8 +1025,6 @@ export const EmbedGif: FC< Math.round(displayHeight * 2), ); const animatedImageSourceRef = useRef(optimizedAnimatedURL); - const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); - const shouldLoadMedia = isNearViewport && !shouldBlur; const { loaded, error, @@ -1049,7 +1054,7 @@ export const EmbedGif: FC< status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle', }); const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed()); - const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating; + const shouldUseDecoder = shouldAnimate && hasStartedAnimating; const decoderPlaying = shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState); const decoderState = useAnimatedImageDecoder({ @@ -1061,7 +1066,7 @@ export const EmbedGif: FC< const useDecoder = shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error; const decoderCanvasVisible = useDecoder && decoderState.loaded; - const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating); + const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating); const setAnimatedImageRef = useCallback( (node: HTMLImageElement | null) => { animatedImageRef.current = node; @@ -1406,7 +1411,7 @@ export const EmbedGif: FC< ...(aspectRatio ? {aspectRatio} : {}), } as React.CSSProperties; const shouldUseAnimatedImage = - shouldLoadMedia && + shouldAnimate && animatedMediaPlaybackAllowed && !decoderCanvasVisible && (gifAutoPlay || (hasStartedAnimating && isHoveredState)); diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx index e0204340e..b3db47a42 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx @@ -356,9 +356,9 @@ const EmbedVideo: FC = observer( () => { if (!active) return; setLoadedPosterSrc(posterSrc); - const image = ImageCacheUtils.getImage(posterSrc); - if (active && image && image.naturalWidth > 0 && image.naturalHeight > 0) { - setPosterNaturalDimensions({src: posterSrc, width: image.naturalWidth, height: image.naturalHeight}); + const posterSize = ImageCacheUtils.getImageSize(posterSrc); + if (active && posterSize) { + setPosterNaturalDimensions({src: posterSrc, width: posterSize.width, height: posterSize.height}); } }, () => { diff --git a/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts b/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts new file mode 100644 index 000000000..d4fa6eb32 --- /dev/null +++ b/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport'; +import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver'; +import {useEffect, useState} from 'react'; + +const ANIMATION_ROOT_MARGIN = '600px 0px'; + +export interface GifViewportGate { + loadMedia: boolean; + animate: boolean; +} + +export function resolveGifViewportGate({ + isNearViewport, + isInViewport, + shouldBlur, +}: { + isNearViewport: boolean; + isInViewport: boolean; + shouldBlur: boolean; +}): GifViewportGate { + if (shouldBlur) return {loadMedia: false, animate: false}; + return {loadMedia: isNearViewport, animate: isNearViewport && isInViewport}; +} + +export function useGifViewportGate({ + element, + rememberKey, + shouldBlur, +}: { + element: Element | null; + rememberKey: string; + shouldBlur: boolean; +}): GifViewportGate & {ref: (node: T | null) => void} { + const {ref, isNearViewport} = useNearViewport({rememberKey}); + const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined'); + useEffect(() => { + if (typeof IntersectionObserver === 'undefined') { + setIsInViewport(true); + return; + } + if (element == null) { + setIsInViewport(false); + return; + } + return observeIntersection( + element, + (entry) => { + setIsInViewport(entry.isIntersecting || entry.intersectionRatio > 0); + }, + {rootMargin: ANIMATION_ROOT_MARGIN}, + ); + }, [element]); + return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})}; +} diff --git a/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts b/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts index 4880baf6f..87442879c 100644 --- a/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts +++ b/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts @@ -9,17 +9,6 @@ export interface TrackedRecipientUser { readonly globalName?: string | null; } -/** - * Builds the MobX tracking key for group-DM display names. - * - * Only the recipients of tracked group DMs are read, so the observing reaction - * depends on those users alone rather than on the whole (unbounded) user cache. - * Reading a recipient that is not cached yet still registers a dependency on - * that key, so a user arriving later re-triggers the reaction. - * - * Kept as a standalone function rather than a class method because - * makeAutoObservable converts methods into actions, and actions run untracked. - */ export const trackedRecipientNameKey = ( snapshots: ReadonlyMap, users: Record, diff --git a/fluxer_app/src/features/channel/state/Channels.ts b/fluxer_app/src/features/channel/state/Channels.ts index 59acd604e..1c1b6eafa 100644 --- a/fluxer_app/src/features/channel/state/Channels.ts +++ b/fluxer_app/src/features/channel/state/Channels.ts @@ -16,7 +16,7 @@ import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/Ch import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; -import {action, makeAutoObservable} from 'mobx'; +import {action, makeAutoObservable, observable} from 'mobx'; const EMPTY_CHANNELS: ReadonlyArray = Object.freeze([]); const sortDMs = (a: Channel, b: Channel) => { @@ -30,12 +30,37 @@ const sortDMs = (a: Channel, b: Channel) => { return b.createdAt.getTime() - a.createdAt.getTime(); }; +const isPrivateChannel = (channel: Channel) => + channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM; + +const insertSorted = (channels: ReadonlyArray, channel: Channel): Array => { + let low = 0; + let high = channels.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ChannelUtils.compareChannels(channels[middle], channel) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + const next = channels.slice(); + next.splice(low, 0, channel); + return next; +}; + class Channels { private readonly channelsById = new Map(); + private readonly channelsByGuildId = new Map>(); + private privateChannelList: ReadonlyArray = EMPTY_CHANNELS; private readonly optimisticChannelBackups = new Map(); constructor() { - makeAutoObservable(this, {}, {autoBind: true}); + makeAutoObservable( + this, + {channelsByGuildId: observable.shallow, privateChannelList: observable.ref}, + {autoBind: true}, + ); } get channels(): ReadonlyArray { @@ -51,31 +76,14 @@ class Channels { readonly dms: ReadonlyArray; readonly privateChannels: ReadonlyArray; } { - const byGuild = new Map>(); - const dms: Array = []; - const privateChannels: Array = []; - for (const channel of this.channelsById.values()) { - if (channel.guildId) { - let list = byGuild.get(channel.guildId); - if (!list) { - list = []; - byGuild.set(channel.guildId, list); - } - list.push(channel); - } else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) { - privateChannels.push(channel); - dms.push(channel); - } - } - for (const list of byGuild.values()) { - list.sort(ChannelUtils.compareChannels); - } - dms.sort(sortDMs); - return {byGuild, dms, privateChannels}; + return {byGuild: this.channelsByGuildId, dms: this.dmChannels, privateChannels: this.privateChannelList}; } get dmChannels(): ReadonlyArray { - return this.channelGroups.dms; + if (this.privateChannelList.length === 0) { + return EMPTY_CHANNELS; + } + return this.privateChannelList.slice().sort(sortDMs); } getChannel(channelId: string): Channel | undefined { @@ -83,11 +91,11 @@ class Channels { } getGuildChannels(guildId: string): ReadonlyArray { - return this.channelGroups.byGuild.get(guildId) ?? EMPTY_CHANNELS; + return this.channelsByGuildId.get(guildId) ?? EMPTY_CHANNELS; } getPrivateChannels(): ReadonlyArray { - return this.channelGroups.privateChannels; + return this.privateChannelList; } @action @@ -100,8 +108,7 @@ class Channels { return; } this.optimisticChannelBackups.set(channelId, channel); - this.channelsById.delete(channelId); - ChannelDisplayName.removeChannel(channelId); + this.deleteChannelRecord(channelId); } @action @@ -122,7 +129,16 @@ class Channels { @action private removeChannel(channelId: string): void { this.clearOptimisticallyRemovedChannel(channelId); + this.deleteChannelRecord(channelId); + } + + @action + private deleteChannelRecord(channelId: string): void { + const channel = this.channelsById.get(channelId); this.channelsById.delete(channelId); + if (channel) { + this.removeChannelFromIndex(channel); + } ChannelDisplayName.removeChannel(channelId); } @@ -134,12 +150,95 @@ class Channels { return; } this.channelsById.set(record.id, record); + this.indexChannel(existing, record); ChannelDisplayName.syncChannel(record); } + @action + private indexChannel(previous: Channel | undefined, next: Channel): void { + if (previous && previous.guildId === next.guildId && isPrivateChannel(previous) === isPrivateChannel(next)) { + this.replaceChannelInIndex(previous, next); + return; + } + if (previous) { + this.removeChannelFromIndex(previous); + } + this.addChannelToIndex(next); + } + + @action + private addChannelToIndex(channel: Channel): void { + if (channel.guildId) { + const list = this.channelsByGuildId.get(channel.guildId) ?? EMPTY_CHANNELS; + this.channelsByGuildId.set(channel.guildId, insertSorted(list, channel)); + return; + } + if (isPrivateChannel(channel)) { + this.privateChannelList = [...this.privateChannelList, channel]; + } + } + + @action + private removeChannelFromIndex(channel: Channel): void { + if (channel.guildId) { + const list = this.channelsByGuildId.get(channel.guildId); + if (!list) { + return; + } + const next = list.filter((entry) => entry.id !== channel.id); + if (next.length === list.length) { + return; + } + if (next.length === 0) { + this.channelsByGuildId.delete(channel.guildId); + } else { + this.channelsByGuildId.set(channel.guildId, next); + } + return; + } + if (isPrivateChannel(channel)) { + const next = this.privateChannelList.filter((entry) => entry.id !== channel.id); + if (next.length !== this.privateChannelList.length) { + this.privateChannelList = next; + } + } + } + + @action + private replaceChannelInIndex(previous: Channel, next: Channel): void { + if (next.guildId) { + const list = this.channelsByGuildId.get(next.guildId); + const index = list ? list.findIndex((entry) => entry.id === next.id) : -1; + if (!list || index === -1) { + this.addChannelToIndex(next); + return; + } + const updated = list.slice(); + updated[index] = next; + if (ChannelUtils.compareChannels(previous, next) !== 0) { + updated.sort(ChannelUtils.compareChannels); + } + this.channelsByGuildId.set(next.guildId, updated); + return; + } + if (!isPrivateChannel(next)) { + return; + } + const index = this.privateChannelList.findIndex((entry) => entry.id === next.id); + if (index === -1) { + this.addChannelToIndex(next); + return; + } + const updated = this.privateChannelList.slice(); + updated[index] = next; + this.privateChannelList = updated; + } + @action handleConnectionOpen({channels}: {channels: ReadonlyArray}): void { this.channelsById.clear(); + this.channelsByGuildId.clear(); + this.privateChannelList = EMPTY_CHANNELS; ChannelDisplayName.clear(); const allRecipients = channels .filter((channel) => channel.recipients && channel.recipients.length > 0) @@ -177,7 +276,7 @@ class Channels { return; } const syncedChannelIds = new Set(guild.channels.map((channel) => channel.id)); - const existingGuildChannels = this.channelGroups.byGuild.get(guild.id) ?? EMPTY_CHANNELS; + const existingGuildChannels = this.getGuildChannels(guild.id); for (const channel of existingGuildChannels) { if (!syncedChannelIds.has(channel.id)) { this.removeChannel(channel.id); @@ -190,8 +289,8 @@ class Channels { @action handleGuildDelete({guildId}: {guildId: string}): void { - const guildChannels = this.channelGroups.byGuild.get(guildId); - if (!guildChannels || guildChannels.length === 0) return; + const guildChannels = this.getGuildChannels(guildId); + if (guildChannels.length === 0) return; const ids: Array = []; for (const channel of guildChannels) ids.push(channel.id); for (const id of ids) { @@ -269,8 +368,7 @@ class Channels { return; } if (user.id === Authentication.currentUserId) { - this.channelsById.delete(channelId); - ChannelDisplayName.removeChannel(channelId); + this.deleteChannelRecord(channelId); const history = RouterUtils.getHistory(); const currentPath = history?.location.pathname ?? ''; const expectedPath = Routes.dmChannel(channelId); @@ -332,8 +430,8 @@ class Channels { @action handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void { - const guildChannels = this.channelGroups.byGuild.get(guildId); - if (!guildChannels || guildChannels.length === 0) return; + const guildChannels = this.getGuildChannels(guildId); + if (guildChannels.length === 0) return; const snapshot = Array.from(guildChannels); for (const channel of snapshot) { if (!(roleId in channel.permissionOverwrites)) { diff --git a/fluxer_app/src/features/expressions/workers/AnimatedImageCropWorkerManager.ts b/fluxer_app/src/features/expressions/workers/AnimatedImageCropWorkerManager.ts index 24c0416bb..7d211dbfc 100644 --- a/fluxer_app/src/features/expressions/workers/AnimatedImageCropWorkerManager.ts +++ b/fluxer_app/src/features/expressions/workers/AnimatedImageCropWorkerManager.ts @@ -43,6 +43,7 @@ interface BatchRequest { const PARALLEL_FRAME_THRESHOLD = 8; const PARALLEL_PIXEL_THRESHOLD = 1_000_000; +const WORKER_IDLE_TIMEOUT_MS = 30_000; export class CropPipelineError extends Error { readonly code: CropWorkerErrorCode; @@ -70,6 +71,8 @@ export class AnimatedImageCropWorkerManager { private readonly pendingBatchRequests = new Set(); private readonly maxWorkers: number; private terminated = false; + private activeJobCount = 0; + private idleTerminationTimeout: NodeJS.Timeout | null = null; private readonly workerTimeout: number = 30000; private constructor() { @@ -197,12 +200,27 @@ export class AnimatedImageCropWorkerManager { if (this.terminated) { throw new CropPipelineError('internal', 'Worker manager has been terminated'); } + this.beginJob(); + return this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).finally(() => { + this.endJob(); + }); + } + + private dispatchLibfluxcoreWorker( + imageBytes: Uint8Array, + format: CropWorkerImageFormat, + options: CropParams, + outputFormat: CropOutputFormat | undefined, + ): Promise { + if (this.terminated) { + return Promise.reject(new CropPipelineError('internal', 'Worker manager has been terminated')); + } this.ensureWorkersInitialized(); const workerState = this.findAvailableWorker(); if (!workerState) { return new Promise((resolve, reject) => { setTimeout(() => { - this.runLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject); + this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject); }, 50); }); } @@ -316,25 +334,30 @@ export class AnimatedImageCropWorkerManager { frames: Array, options: CropOptionsEx, ): Promise> { - this.ensureWorkersInitialized(); - const availableWorkers = this.workers.filter((worker) => !worker.busy); - if (availableWorkers.length <= 1) { - return frames.map((frame) => cropDecodedFrame(frame, options)); + this.beginJob(); + try { + this.ensureWorkersInitialized(); + const availableWorkers = this.workers.filter((worker) => !worker.busy); + if (availableWorkers.length <= 1) { + return frames.map((frame) => cropDecodedFrame(frame, options)); + } + const batches = createFrameBatches(frames, availableWorkers.length); + const results = await Promise.all( + batches.map((batch, index) => + this.processFrameBatchWithWorker(availableWorkers[index], index + 1, batch, options), + ), + ); + const ordered: Array = new Array(frames.length); + for (const batch of results) { + for (const item of batch) ordered[item.index] = item.frame; + } + return ordered.map((frame, index) => { + if (!frame) throw new CropPipelineError('internal', `Missing transformed frame ${index}`); + return frame; + }); + } finally { + this.endJob(); } - const batches = createFrameBatches(frames, availableWorkers.length); - const results = await Promise.all( - batches.map((batch, index) => - this.processFrameBatchWithWorker(availableWorkers[index], index + 1, batch, options), - ), - ); - const ordered: Array = new Array(frames.length); - for (const batch of results) { - for (const item of batch) ordered[item.index] = item.frame; - } - return ordered.map((frame, index) => { - if (!frame) throw new CropPipelineError('internal', `Missing transformed frame ${index}`); - return frame; - }); } private processFrameBatchWithWorker( @@ -417,6 +440,54 @@ export class AnimatedImageCropWorkerManager { return null; } + private beginJob(): void { + this.clearIdleTermination(); + this.activeJobCount += 1; + } + + private endJob(): void { + this.activeJobCount = Math.max(0, this.activeJobCount - 1); + this.scheduleIdleTermination(); + } + + private isPoolIdle(): boolean { + return ( + this.activeJobCount === 0 && + this.pendingBatchRequests.size === 0 && + this.workers.every((workerState) => !workerState.busy && workerState.currentRequest === null) + ); + } + + private clearIdleTermination(): void { + if (this.idleTerminationTimeout === null) { + return; + } + clearTimeout(this.idleTerminationTimeout); + this.idleTerminationTimeout = null; + } + + private scheduleIdleTermination(): void { + this.clearIdleTermination(); + if (this.terminated || this.workers.length === 0 || !this.isPoolIdle()) { + return; + } + this.idleTerminationTimeout = setTimeout(() => { + this.idleTerminationTimeout = null; + if (this.isPoolIdle()) { + this.terminateIdleWorkers(); + } + }, WORKER_IDLE_TIMEOUT_MS); + } + + private terminateIdleWorkers(): void { + for (const workerState of this.workers) { + try { + workerState.worker.terminate(); + } catch {} + } + this.workers = []; + } + getActiveWorkerCount(): number { return this.workers.filter((w) => w.busy).length; } @@ -430,6 +501,7 @@ export class AnimatedImageCropWorkerManager { return; } this.terminated = true; + this.clearIdleTermination(); for (const workerState of this.workers) { if (workerState.currentRequest) { clearTimeout(workerState.currentRequest.timeout); @@ -453,6 +525,7 @@ export class AnimatedImageCropWorkerManager { } this.terminated = false; this.ensureWorkersInitialized(); + this.scheduleIdleTermination(); } } diff --git a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts index c0fcece85..98a73a7d6 100644 --- a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts +++ b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts @@ -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; @@ -77,15 +77,15 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav onEscape, allowWhenInactive = false, } = options; - const messageNodesCache = useRef(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, diff --git a/fluxer_app/src/features/messaging/state/MessageReactions.ts b/fluxer_app/src/features/messaging/state/MessageReactions.ts index 6e782f9e8..d53ca7664 100644 --- a/fluxer_app/src/features/messaging/state/MessageReactions.ts +++ b/fluxer_app/src/features/messaging/state/MessageReactions.ts @@ -39,6 +39,7 @@ type Listener = () => void; const EMPTY_REACTIONS: ReadonlyArray = Object.freeze([]); const EMPTY_USERS: ReadonlyArray = 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 = new Map(); private _keysByMessage: Map> = new Map(); private retiredReactionRequests: Map = new Map(); + private inFlightReactionRequests: Map> = new Map(); private messageListeners: Map> = new Map(); private reactionListeners: Map> = 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(); + 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 { diff --git a/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts b/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts index 62ebba566..2ba4a83bb 100644 --- a/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts +++ b/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts @@ -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({ 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); } diff --git a/fluxer_app/src/features/platform/utils/LibFluxcore.ts b/fluxer_app/src/features/platform/utils/LibFluxcore.ts index 665fafcbc..80fbb0851 100644 --- a/fluxer_app/src/features/platform/utils/LibFluxcore.ts +++ b/fluxer_app/src/features/platform/utils/LibFluxcore.ts @@ -10,7 +10,9 @@ const MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES = 64 * 1024 * 1024; let modulePromise: Promise | null = null; let moduleReady = false; let moduleExports: InitOutput | null = null; -let activeZstdStreamHandles = 0; +let pendingModuleOperations = 0; +const liveZstdStreamDecoders = new Set(); +const liveZstdStreamEncoders = new Set(); export interface RgbaTransformResult { rgba: Uint8Array; @@ -58,6 +60,28 @@ async function loadModule(): Promise { await modulePromise; } +function isLibfluxcoreModulePinned(): boolean { + return pendingModuleOperations > 0 || liveZstdStreamDecoders.size > 0 || liveZstdStreamEncoders.size > 0; +} + +async function withPinnedLibfluxcoreModule(run: () => T): Promise { + pendingModuleOperations++; + try { + await loadModule(); + return run(); + } finally { + pendingModuleOperations--; + releaseLibfluxcoreMemoryIfIdle(); + } +} + +function requireLiveZstdStreamHandle(handle: number, live: Set): number { + if (!live.has(handle)) { + throw new Error('libfluxcore zstd stream handle is no longer live'); + } + return handle; +} + export function isLibfluxcoreReady(): boolean { return moduleReady; } @@ -67,7 +91,7 @@ export async function ensureLibfluxcoreReady(): Promise { } export function releaseLibfluxcoreMemoryIfIdle(): void { - if (activeZstdStreamHandles > 0) return; + if (isLibfluxcoreModulePinned()) return; if ((moduleExports?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES) return; wasm.__resetLibfluxcoreWasmForMemoryPressure(); modulePromise = null; @@ -76,13 +100,7 @@ export function releaseLibfluxcoreMemoryIfIdle(): void { } export async function detectAnimatedImage(data: Uint8Array): Promise { - await loadModule(); - try { - const result = wasm.is_animated_image(data); - return Boolean(result); - } finally { - releaseLibfluxcoreMemoryIfIdle(); - } + return withPinnedLibfluxcoreModule(() => Boolean(wasm.is_animated_image(data))); } export function cropAndRotateGif( @@ -210,70 +228,70 @@ export async function cropAndRotateApng( resizeWidth: number | null, resizeHeight: number | null, ): Promise { - await loadModule(); - try { + return withPinnedLibfluxcoreModule(() => { const result = wasm.crop_and_rotate_apng(apng, x, y, width, height, rotation, resizeWidth, resizeHeight); return result instanceof Uint8Array ? result : new Uint8Array(result); - } finally { - releaseLibfluxcoreMemoryIfIdle(); - } + }); } export async function decompressZstdFrame(input: Uint8Array): Promise { - await loadModule(); - try { + return withPinnedLibfluxcoreModule(() => { const result = wasm.decompress_zstd_frame(input); return result instanceof Uint8Array ? result : new Uint8Array(result); - } finally { - releaseLibfluxcoreMemoryIfIdle(); - } + }); } export async function createZstdStreamDecoder(): Promise { - await loadModule(); - const decoder = wasm.create_zstd_stream_decoder(); - activeZstdStreamHandles++; - return decoder; + return withPinnedLibfluxcoreModule(() => { + const decoder = wasm.create_zstd_stream_decoder(); + liveZstdStreamDecoders.add(decoder); + return decoder; + }); } export async function decompressZstdStreamChunk(decoder: number, input: Uint8Array): Promise { - await loadModule(); - const result = wasm.decompress_zstd_stream_chunk(decoder, input); - return result instanceof Uint8Array ? result : new Uint8Array(result); + return withPinnedLibfluxcoreModule(() => { + const result = wasm.decompress_zstd_stream_chunk( + requireLiveZstdStreamHandle(decoder, liveZstdStreamDecoders), + input, + ); + return result instanceof Uint8Array ? result : new Uint8Array(result); + }); } export function freeZstdStreamDecoder(decoder: number): void { + if (!liveZstdStreamDecoders.delete(decoder)) return; try { wasm.free_zstd_stream_decoder(decoder); } finally { - activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1); releaseLibfluxcoreMemoryIfIdle(); } } export async function createZstdStreamEncoder(level: number): Promise { - await loadModule(); - const encoder = wasm.create_zstd_stream_encoder(level); - activeZstdStreamHandles++; - return encoder; + return withPinnedLibfluxcoreModule(() => { + const encoder = wasm.create_zstd_stream_encoder(level); + liveZstdStreamEncoders.add(encoder); + return encoder; + }); } export function createZstdStreamEncoderSync(level: number): number { const encoder = wasm.create_zstd_stream_encoder(level); - activeZstdStreamHandles++; + liveZstdStreamEncoders.add(encoder); return encoder; } export function compressZstdStreamChunk(encoder: number, input: Uint8Array): Uint8Array { - const result = wasm.compress_zstd_stream_chunk(encoder, input); + const result = wasm.compress_zstd_stream_chunk(requireLiveZstdStreamHandle(encoder, liveZstdStreamEncoders), input); return result instanceof Uint8Array ? result : new Uint8Array(result); } export function freeZstdStreamEncoder(encoder: number): void { + if (!liveZstdStreamEncoders.delete(encoder)) return; try { wasm.free_zstd_stream_encoder(encoder); } finally { - activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1); releaseLibfluxcoreMemoryIfIdle(); } } diff --git a/fluxer_app/src/features/user/models/User.ts b/fluxer_app/src/features/user/models/User.ts index 2f70305bb..132ce803e 100644 --- a/fluxer_app/src/features/user/models/User.ts +++ b/fluxer_app/src/features/user/models/User.ts @@ -648,6 +648,7 @@ export class User { this._premiumType === other._premiumType && datesEqual(this._premiumSince, other._premiumSince) && datesEqual(this._premiumUntil, other._premiumUntil) && + datesEqual(this._premiumGraceEndsAt, other._premiumGraceEndsAt) && this._premiumWillCancel === other._premiumWillCancel && this._premiumBillingCycle === other._premiumBillingCycle && this._premiumLifetimeSequence === other._premiumLifetimeSequence && diff --git a/fluxer_app/src/features/user/state/Users.ts b/fluxer_app/src/features/user/state/Users.ts index 3258d2841..bf9c21c7e 100644 --- a/fluxer_app/src/features/user/state/Users.ts +++ b/fluxer_app/src/features/user/state/Users.ts @@ -55,6 +55,7 @@ function isPublicOnlyCurrentUserPayload(user: WireUser): boolean { class Users { users: Record = {}; + userCount = 0; constructor() { makeAutoObservable(this, {}, {autoBind: true}); @@ -98,6 +99,7 @@ class Users { this.users = { [currentUser.id]: userRecord, }; + this.userCount = 1; if (!userRecord.isClaimed()) { setTimeout(async () => { openClaimAccountModal(); @@ -121,7 +123,18 @@ class Users { ) { return; } - this.users[user.id] = existingUser ? existingUser.withUpdates(user, options) : new User(user); + this.storeUser(existingUser, existingUser ? existingUser.withUpdates(user, options) : new User(user)); + } + + private storeUser(existingUser: User | undefined, nextUser: User): void { + if (existingUser) { + if (existingUser.equals(nextUser)) { + return; + } + } else { + this.userCount += 1; + } + this.users[nextUser.id] = nextUser; } cacheUsers( @@ -137,14 +150,14 @@ class Users { if (user.id === this.currentUserId && existingUser && isPublicOnlyCurrentUserPayload(user)) { continue; } - this.users[user.id] = existingUser ? existingUser.withUpdates(user) : new User(user); + this.storeUser(existingUser, existingUser ? existingUser.withUpdates(user) : new User(user)); } }); } subscribe(callback: () => void): () => void { return reaction( - () => Object.keys(this.users).length, + () => this.userCount, () => callback(), {fireImmediately: true}, );