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

@@ -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<HTMLDivElement>({
autoFocusFirst: true,
focusableSelector: '[data-roving-focus="true"]',
manageTabIndex: true,
});
return (
<div
ref={listRef}
role="menu"
aria-orientation="vertical"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menu"
>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button"
>
First
</button>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button--2"
>
Second
</button>
<div
role="menuitemcheckbox"
aria-checked="false"
tabIndex={0}
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitemcheckbox"
>
Third
</div>
</div>
);
}
function ManualEntryRovingFocusProbe() {
const listRef = useRovingFocusList<HTMLDivElement>({
focusableSelector: '[data-roving-focus="true"]',
manageTabIndex: true,
});
return (
<div
ref={listRef}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
data-autofocus
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menu"
>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button"
>
First
</button>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button--2"
>
Second
</button>
</div>
);
}
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<HTMLElement> =>
Array.from(container.querySelectorAll<HTMLElement>('[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(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe" />);
});
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(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--2" />);
});
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(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--3" />);
});
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(
<ManualEntryRovingFocusProbe data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe" />,
);
});
const menu = container?.querySelector<HTMLElement>('[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]);
});
});
@@ -21,6 +21,7 @@ import {
reduceGIFVRenderState, reduceGIFVRenderState,
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine'; } from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator'; 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 {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer'; import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes'; import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
@@ -985,9 +986,15 @@ export const EmbedGif: FC<
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
const animatedImageRef = useRef<HTMLImageElement>(null); const animatedImageRef = useRef<HTMLImageElement>(null);
const freezeCanvasRef = useRef<HTMLCanvasElement>(null); const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({ const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
disabled: !isMobile, const {
ref: visibilityRef,
loadMedia: shouldLoadMedia,
animate: shouldAnimate,
} = useGifViewportGate<HTMLDivElement>({
element: containerElement,
rememberKey: proxyURL, rememberKey: proxyURL,
shouldBlur,
}); });
const setContainerRef = useCallback( const setContainerRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
@@ -1018,8 +1025,6 @@ export const EmbedGif: FC<
Math.round(displayHeight * 2), Math.round(displayHeight * 2),
); );
const animatedImageSourceRef = useRef(optimizedAnimatedURL); const animatedImageSourceRef = useRef(optimizedAnimatedURL);
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
const shouldLoadMedia = isNearViewport && !shouldBlur;
const { const {
loaded, loaded,
error, error,
@@ -1049,7 +1054,7 @@ export const EmbedGif: FC<
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle', status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
}); });
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed()); const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating; const shouldUseDecoder = shouldAnimate && hasStartedAnimating;
const decoderPlaying = const decoderPlaying =
shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState); shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState);
const decoderState = useAnimatedImageDecoder({ const decoderState = useAnimatedImageDecoder({
@@ -1061,7 +1066,7 @@ export const EmbedGif: FC<
const useDecoder = const useDecoder =
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error; shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
const decoderCanvasVisible = useDecoder && decoderState.loaded; const decoderCanvasVisible = useDecoder && decoderState.loaded;
const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating); const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
const setAnimatedImageRef = useCallback( const setAnimatedImageRef = useCallback(
(node: HTMLImageElement | null) => { (node: HTMLImageElement | null) => {
animatedImageRef.current = node; animatedImageRef.current = node;
@@ -1406,7 +1411,7 @@ export const EmbedGif: FC<
...(aspectRatio ? {aspectRatio} : {}), ...(aspectRatio ? {aspectRatio} : {}),
} as React.CSSProperties; } as React.CSSProperties;
const shouldUseAnimatedImage = const shouldUseAnimatedImage =
shouldLoadMedia && shouldAnimate &&
animatedMediaPlaybackAllowed && animatedMediaPlaybackAllowed &&
!decoderCanvasVisible && !decoderCanvasVisible &&
(gifAutoPlay || (hasStartedAnimating && isHoveredState)); (gifAutoPlay || (hasStartedAnimating && isHoveredState));
@@ -356,9 +356,9 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
() => { () => {
if (!active) return; if (!active) return;
setLoadedPosterSrc(posterSrc); setLoadedPosterSrc(posterSrc);
const image = ImageCacheUtils.getImage(posterSrc); const posterSize = ImageCacheUtils.getImageSize(posterSrc);
if (active && image && image.naturalWidth > 0 && image.naturalHeight > 0) { if (active && posterSize) {
setPosterNaturalDimensions({src: posterSrc, width: image.naturalWidth, height: image.naturalHeight}); setPosterNaturalDimensions({src: posterSrc, width: posterSize.width, height: posterSize.height});
} }
}, },
() => { () => {
@@ -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<T extends Element>({
element,
rememberKey,
shouldBlur,
}: {
element: Element | null;
rememberKey: string;
shouldBlur: boolean;
}): GifViewportGate & {ref: (node: T | null) => void} {
const {ref, isNearViewport} = useNearViewport<T>({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})};
}
@@ -9,17 +9,6 @@ export interface TrackedRecipientUser {
readonly globalName?: string | null; 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 = ( export const trackedRecipientNameKey = (
snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>, snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>,
users: Record<string, TrackedRecipientUser | undefined>, users: Record<string, TrackedRecipientUser | undefined>,
+133 -35
View File
@@ -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 {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import {action, makeAutoObservable} from 'mobx'; import {action, makeAutoObservable, observable} from 'mobx';
const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]); const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]);
const sortDMs = (a: Channel, b: Channel) => { const sortDMs = (a: Channel, b: Channel) => {
@@ -30,12 +30,37 @@ const sortDMs = (a: Channel, b: Channel) => {
return b.createdAt.getTime() - a.createdAt.getTime(); 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: Channel): Array<Channel> => {
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 { class Channels {
private readonly channelsById = new Map<string, Channel>(); private readonly channelsById = new Map<string, Channel>();
private readonly channelsByGuildId = new Map<string, ReadonlyArray<Channel>>();
private privateChannelList: ReadonlyArray<Channel> = EMPTY_CHANNELS;
private readonly optimisticChannelBackups = new Map<string, Channel>(); private readonly optimisticChannelBackups = new Map<string, Channel>();
constructor() { constructor() {
makeAutoObservable(this, {}, {autoBind: true}); makeAutoObservable<this, 'channelsByGuildId' | 'privateChannelList'>(
this,
{channelsByGuildId: observable.shallow, privateChannelList: observable.ref},
{autoBind: true},
);
} }
get channels(): ReadonlyArray<Channel> { get channels(): ReadonlyArray<Channel> {
@@ -51,31 +76,14 @@ class Channels {
readonly dms: ReadonlyArray<Channel>; readonly dms: ReadonlyArray<Channel>;
readonly privateChannels: ReadonlyArray<Channel>; readonly privateChannels: ReadonlyArray<Channel>;
} { } {
const byGuild = new Map<string, Array<Channel>>(); return {byGuild: this.channelsByGuildId, dms: this.dmChannels, privateChannels: this.privateChannelList};
const dms: Array<Channel> = [];
const privateChannels: Array<Channel> = [];
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};
} }
get dmChannels(): ReadonlyArray<Channel> { get dmChannels(): ReadonlyArray<Channel> {
return this.channelGroups.dms; if (this.privateChannelList.length === 0) {
return EMPTY_CHANNELS;
}
return this.privateChannelList.slice().sort(sortDMs);
} }
getChannel(channelId: string): Channel | undefined { getChannel(channelId: string): Channel | undefined {
@@ -83,11 +91,11 @@ class Channels {
} }
getGuildChannels(guildId: string): ReadonlyArray<Channel> { getGuildChannels(guildId: string): ReadonlyArray<Channel> {
return this.channelGroups.byGuild.get(guildId) ?? EMPTY_CHANNELS; return this.channelsByGuildId.get(guildId) ?? EMPTY_CHANNELS;
} }
getPrivateChannels(): ReadonlyArray<Channel> { getPrivateChannels(): ReadonlyArray<Channel> {
return this.channelGroups.privateChannels; return this.privateChannelList;
} }
@action @action
@@ -100,8 +108,7 @@ class Channels {
return; return;
} }
this.optimisticChannelBackups.set(channelId, channel); this.optimisticChannelBackups.set(channelId, channel);
this.channelsById.delete(channelId); this.deleteChannelRecord(channelId);
ChannelDisplayName.removeChannel(channelId);
} }
@action @action
@@ -122,7 +129,16 @@ class Channels {
@action @action
private removeChannel(channelId: string): void { private removeChannel(channelId: string): void {
this.clearOptimisticallyRemovedChannel(channelId); this.clearOptimisticallyRemovedChannel(channelId);
this.deleteChannelRecord(channelId);
}
@action
private deleteChannelRecord(channelId: string): void {
const channel = this.channelsById.get(channelId);
this.channelsById.delete(channelId); this.channelsById.delete(channelId);
if (channel) {
this.removeChannelFromIndex(channel);
}
ChannelDisplayName.removeChannel(channelId); ChannelDisplayName.removeChannel(channelId);
} }
@@ -134,12 +150,95 @@ class Channels {
return; return;
} }
this.channelsById.set(record.id, record); this.channelsById.set(record.id, record);
this.indexChannel(existing, record);
ChannelDisplayName.syncChannel(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 @action
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void { handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void {
this.channelsById.clear(); this.channelsById.clear();
this.channelsByGuildId.clear();
this.privateChannelList = EMPTY_CHANNELS;
ChannelDisplayName.clear(); ChannelDisplayName.clear();
const allRecipients = channels const allRecipients = channels
.filter((channel) => channel.recipients && channel.recipients.length > 0) .filter((channel) => channel.recipients && channel.recipients.length > 0)
@@ -177,7 +276,7 @@ class Channels {
return; return;
} }
const syncedChannelIds = new Set(guild.channels.map((channel) => channel.id)); 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) { for (const channel of existingGuildChannels) {
if (!syncedChannelIds.has(channel.id)) { if (!syncedChannelIds.has(channel.id)) {
this.removeChannel(channel.id); this.removeChannel(channel.id);
@@ -190,8 +289,8 @@ class Channels {
@action @action
handleGuildDelete({guildId}: {guildId: string}): void { handleGuildDelete({guildId}: {guildId: string}): void {
const guildChannels = this.channelGroups.byGuild.get(guildId); const guildChannels = this.getGuildChannels(guildId);
if (!guildChannels || guildChannels.length === 0) return; if (guildChannels.length === 0) return;
const ids: Array<string> = []; const ids: Array<string> = [];
for (const channel of guildChannels) ids.push(channel.id); for (const channel of guildChannels) ids.push(channel.id);
for (const id of ids) { for (const id of ids) {
@@ -269,8 +368,7 @@ class Channels {
return; return;
} }
if (user.id === Authentication.currentUserId) { if (user.id === Authentication.currentUserId) {
this.channelsById.delete(channelId); this.deleteChannelRecord(channelId);
ChannelDisplayName.removeChannel(channelId);
const history = RouterUtils.getHistory(); const history = RouterUtils.getHistory();
const currentPath = history?.location.pathname ?? ''; const currentPath = history?.location.pathname ?? '';
const expectedPath = Routes.dmChannel(channelId); const expectedPath = Routes.dmChannel(channelId);
@@ -332,8 +430,8 @@ class Channels {
@action @action
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void { handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
const guildChannels = this.channelGroups.byGuild.get(guildId); const guildChannels = this.getGuildChannels(guildId);
if (!guildChannels || guildChannels.length === 0) return; if (guildChannels.length === 0) return;
const snapshot = Array.from(guildChannels); const snapshot = Array.from(guildChannels);
for (const channel of snapshot) { for (const channel of snapshot) {
if (!(roleId in channel.permissionOverwrites)) { if (!(roleId in channel.permissionOverwrites)) {
@@ -43,6 +43,7 @@ interface BatchRequest {
const PARALLEL_FRAME_THRESHOLD = 8; const PARALLEL_FRAME_THRESHOLD = 8;
const PARALLEL_PIXEL_THRESHOLD = 1_000_000; const PARALLEL_PIXEL_THRESHOLD = 1_000_000;
const WORKER_IDLE_TIMEOUT_MS = 30_000;
export class CropPipelineError extends Error { export class CropPipelineError extends Error {
readonly code: CropWorkerErrorCode; readonly code: CropWorkerErrorCode;
@@ -70,6 +71,8 @@ export class AnimatedImageCropWorkerManager {
private readonly pendingBatchRequests = new Set<BatchRequest>(); private readonly pendingBatchRequests = new Set<BatchRequest>();
private readonly maxWorkers: number; private readonly maxWorkers: number;
private terminated = false; private terminated = false;
private activeJobCount = 0;
private idleTerminationTimeout: NodeJS.Timeout | null = null;
private readonly workerTimeout: number = 30000; private readonly workerTimeout: number = 30000;
private constructor() { private constructor() {
@@ -197,12 +200,27 @@ export class AnimatedImageCropWorkerManager {
if (this.terminated) { if (this.terminated) {
throw new CropPipelineError('internal', 'Worker manager has been 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<Uint8Array> {
if (this.terminated) {
return Promise.reject(new CropPipelineError('internal', 'Worker manager has been terminated'));
}
this.ensureWorkersInitialized(); this.ensureWorkersInitialized();
const workerState = this.findAvailableWorker(); const workerState = this.findAvailableWorker();
if (!workerState) { if (!workerState) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
this.runLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject); this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
}, 50); }, 50);
}); });
} }
@@ -316,25 +334,30 @@ export class AnimatedImageCropWorkerManager {
frames: Array<NativeFrame>, frames: Array<NativeFrame>,
options: CropOptionsEx, options: CropOptionsEx,
): Promise<Array<NativeFrame>> { ): Promise<Array<NativeFrame>> {
this.ensureWorkersInitialized(); this.beginJob();
const availableWorkers = this.workers.filter((worker) => !worker.busy); try {
if (availableWorkers.length <= 1) { this.ensureWorkersInitialized();
return frames.map((frame) => cropDecodedFrame(frame, options)); 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<NativeFrame | undefined> = 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<NativeFrame | undefined> = 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( private processFrameBatchWithWorker(
@@ -417,6 +440,54 @@ export class AnimatedImageCropWorkerManager {
return null; 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 { getActiveWorkerCount(): number {
return this.workers.filter((w) => w.busy).length; return this.workers.filter((w) => w.busy).length;
} }
@@ -430,6 +501,7 @@ export class AnimatedImageCropWorkerManager {
return; return;
} }
this.terminated = true; this.terminated = true;
this.clearIdleTermination();
for (const workerState of this.workers) { for (const workerState of this.workers) {
if (workerState.currentRequest) { if (workerState.currentRequest) {
clearTimeout(workerState.currentRequest.timeout); clearTimeout(workerState.currentRequest.timeout);
@@ -453,6 +525,7 @@ export class AnimatedImageCropWorkerManager {
} }
this.terminated = false; this.terminated = false;
this.ensureWorkersInitialized(); this.ensureWorkersInitialized();
this.scheduleIdleTermination();
} }
} }
@@ -3,7 +3,7 @@
import MessageFocus from '@app/features/messaging/state/MessageFocus'; import MessageFocus from '@app/features/messaging/state/MessageFocus';
import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {type RefObject, useEffect, useRef} from 'react'; import {type RefObject, useEffect} from 'react';
interface MessageListKeyboardNavigationOptions { interface MessageListKeyboardNavigationOptions {
containerRef?: RefObject<ScrollerHandle | HTMLElement | null>; containerRef?: RefObject<ScrollerHandle | HTMLElement | null>;
@@ -77,15 +77,15 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
onEscape, onEscape,
allowWhenInactive = false, allowWhenInactive = false,
} = options; } = options;
const messageNodesCache = useRef<MessageNodesSnapshot>(EMPTY_MESSAGE_NODES_SNAPSHOT);
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled; const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
useEffect(() => { useEffect(() => {
if (!keyboardModeEnabled) return; if (!keyboardModeEnabled) return;
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
let observedRoot: ParentNode | null = null; let observedRoot: ParentNode | null = null;
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
const invalidateMessageNodesCache = () => { const invalidateMessageNodesCache = () => {
messageNodesCache.current = { messageNodesCache = {
...messageNodesCache.current, ...messageNodesCache,
nodes: [], nodes: [],
indexById: new Map(), indexById: new Map(),
ts: 0, ts: 0,
@@ -108,14 +108,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
}; };
const getMessageElementsSnapshot = (): MessageNodesSnapshot => { const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
const now = Date.now(); const now = Date.now();
const cache = messageNodesCache.current; const cache = messageNodesCache;
const container = getScrollerNode(containerRef?.current ?? null); const container = getScrollerNode(containerRef?.current ?? null);
if (!container && containerRef) { if (!container && containerRef) {
messageNodesCache.current = { messageNodesCache = {
...EMPTY_MESSAGE_NODES_SNAPSHOT, ...EMPTY_MESSAGE_NODES_SNAPSHOT,
ts: now, ts: now,
}; };
return messageNodesCache.current; return messageNodesCache;
} }
const root = container ?? document; const root = container ?? document;
const selector = getMessageSelector(channelId); const selector = getMessageSelector(channelId);
@@ -131,14 +131,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
indexById.set(messageId, i); indexById.set(messageId, i);
} }
} }
messageNodesCache.current = { messageNodesCache = {
nodes, nodes,
indexById, indexById,
root, root,
selector, selector,
ts: now, ts: now,
}; };
return messageNodesCache.current; return messageNodesCache;
}; };
const focusNode = (node: HTMLElement, messageId: string) => { const focusNode = (node: HTMLElement, messageId: string) => {
if (onFocusMessage) { if (onFocusMessage) {
@@ -220,6 +220,9 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown, true); window.removeEventListener('keydown', handleKeyDown, true);
observer?.disconnect(); observer?.disconnect();
observer = null;
observedRoot = null;
messageNodesCache = EMPTY_MESSAGE_NODES_SNAPSHOT;
}; };
}, [ }, [
keyboardModeEnabled, keyboardModeEnabled,
@@ -39,6 +39,7 @@ type Listener = () => void;
const EMPTY_REACTIONS: ReadonlyArray<MessageReaction> = Object.freeze([]); const EMPTY_REACTIONS: ReadonlyArray<MessageReaction> = Object.freeze([]);
const EMPTY_USERS: ReadonlyArray<User> = Object.freeze([]); const EMPTY_USERS: ReadonlyArray<User> = Object.freeze([]);
const MAX_TRACKED_REACTION_REQUEST_KEYS = 512;
const createEmptyReactorEntry = (requestSerial = 0): ReactorEntry => ({ const createEmptyReactorEntry = (requestSerial = 0): ReactorEntry => ({
snapshot: createReactionUsersSnapshot(requestSerial), snapshot: createReactionUsersSnapshot(requestSerial),
}); });
@@ -68,6 +69,7 @@ export class MessageReactionsManager {
private reactors: Map<string, ReactorEntry> = new Map(); private reactors: Map<string, ReactorEntry> = new Map();
private _keysByMessage: Map<string, Set<string>> = new Map(); private _keysByMessage: Map<string, Set<string>> = new Map();
private retiredReactionRequests: Map<string, number> = 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 messageListeners: Map<string, Set<Listener>> = new Map();
private reactionListeners: Map<string, Set<Listener>> = new Map(); private reactionListeners: Map<string, Set<Listener>> = new Map();
private transactionDepth = 0; private transactionDepth = 0;
@@ -247,7 +249,9 @@ export class MessageReactionsManager {
handleFetchPending(messageId: string, emoji: ReactionEmoji): number { handleFetchPending(messageId: string, emoji: ReactionEmoji): number {
const entry = this.getOrCreateReactorEntry(messageId, emoji); const entry = this.getOrCreateReactorEntry(messageId, emoji);
this.commitReactorEvent(messageId, emoji, entry, {type: 'fetch.pending'}); 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( handleFetchSuccess(
@@ -261,7 +265,9 @@ export class MessageReactionsManager {
nextAfter?: string | null, nextAfter?: string | null,
): void { ): void {
const key = getReactionKey(messageId, emoji); 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); const entry = this.getOrCreateReactorEntry(messageId, emoji);
Users.cacheUsers(users.slice()); Users.cacheUsers(users.slice());
this.commitReactorEvent(messageId, emoji, entry, { this.commitReactorEvent(messageId, emoji, entry, {
@@ -288,7 +294,9 @@ export class MessageReactionsManager {
nextAfter?: string | null, nextAfter?: string | null,
): void { ): void {
const key = getReactionKey(messageId, emoji); 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); const entry = this.getOrCreateReactorEntry(messageId, emoji);
Users.cacheUsers(users.slice()); Users.cacheUsers(users.slice());
this.commitReactorEvent(messageId, emoji, entry, { this.commitReactorEvent(messageId, emoji, entry, {
@@ -306,7 +314,9 @@ export class MessageReactionsManager {
handleFetchError(messageId: string, emoji: ReactionEmoji, requestId?: number): void { handleFetchError(messageId: string, emoji: ReactionEmoji, requestId?: number): void {
const key = getReactionKey(messageId, emoji); 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), { this.commitReactorEvent(messageId, emoji, this.getOrCreateReactorEntry(messageId, emoji), {
type: 'fetch.error', type: 'fetch.error',
requestId, requestId,
@@ -370,10 +380,37 @@ export class MessageReactionsManager {
} }
private retireReactionEntry(key: string, entry = this.reactors.get(key)): void { 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 requestSerial = entry?.snapshot.context.requestSerial ?? 0;
const nextRequestId = Math.max(this.retiredReactionRequests.get(key) ?? 0, requestSerial); const nextRequestId = Math.max(this.retiredReactionRequests.get(key) ?? 0, requestSerial);
this.retiredReactionRequests.set(key, nextRequestId); 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 { private shouldIgnoreFetchResult(key: string, requestId?: number): boolean {
@@ -3,8 +3,15 @@
import {LRUCache} from 'lru-cache'; import {LRUCache} from 'lru-cache';
interface ImageCacheEntry { interface ImageCacheEntry {
image: HTMLImageElement;
src: string; src: string;
naturalWidth: number;
naturalHeight: number;
pin: HTMLImageElement;
}
export interface CachedImageSize {
width: number;
height: number;
} }
interface PendingImageSubscriber { interface PendingImageSubscriber {
@@ -31,9 +38,7 @@ const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
const IMAGE_LOAD_TIMEOUT_MS = 30_000; const IMAGE_LOAD_TIMEOUT_MS = 30_000;
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000; const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
const getImageByteSize = (image: HTMLImageElement): number | null => { const getDimensionByteSize = (width: number, height: number): number | null => {
const width = image.naturalWidth;
const height = image.naturalHeight;
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
return null; return null;
} }
@@ -42,12 +47,23 @@ const getImageByteSize = (image: HTMLImageElement): number | null => {
return byteSize; return byteSize;
}; };
const getImageByteSize = (image: HTMLImageElement): number | null =>
getDimensionByteSize(image.naturalWidth, image.naturalHeight);
const estimateImageBytes = (entry: ImageCacheEntry): number => { 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; if (byteSize == null) return MAX_CACHE_ENTRY_BYTES + FALLBACK_IMAGE_BYTES;
return byteSize; 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>({ const imageCache = new LRUCache<string, ImageCacheEntry>({
max: MAX_CACHE_ENTRIES, max: MAX_CACHE_ENTRIES,
maxSize: MAX_CACHE_BYTES, maxSize: MAX_CACHE_BYTES,
@@ -91,7 +107,7 @@ const isCached = (src: string | null): boolean => {
if (!acceptsImageSource(src)) return false; if (!acceptsImageSource(src)) return false;
const entry = imageCache.get(src); const entry = imageCache.get(src);
if (!entry) return false; 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); imageCache.delete(src);
return false; return false;
}; };
@@ -100,12 +116,13 @@ export function hasImage(src: string | null): boolean {
return isCached(src); return isCached(src);
} }
export function getImage(src: string | null): HTMLImageElement | undefined { export function getImageSize(src: string | null): CachedImageSize | undefined {
if (!acceptsImageSource(src)) return undefined; if (!acceptsImageSource(src)) return undefined;
const entry = imageCache.get(src); const entry = imageCache.get(src);
if (!entry) return undefined; if (!entry) return undefined;
const image = entry.image; if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) {
if (entry.src === src && imageHasSource(image, src) && isLoadedImage(image)) return image; return {width: entry.naturalWidth, height: entry.naturalHeight};
}
imageCache.delete(src); imageCache.delete(src);
return undefined; return undefined;
} }
@@ -118,7 +135,14 @@ export function rememberImage(src: string | null, image: HTMLImageElement): void
} }
return; 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; if (!currentPendingLoad) return;
settlePendingImageLoad(src, currentPendingLoad, true, image); settlePendingImageLoad(src, currentPendingLoad, true, image);
} }
@@ -10,7 +10,9 @@ const MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES = 64 * 1024 * 1024;
let modulePromise: Promise<void> | null = null; let modulePromise: Promise<void> | null = null;
let moduleReady = false; let moduleReady = false;
let moduleExports: InitOutput | null = null; let moduleExports: InitOutput | null = null;
let activeZstdStreamHandles = 0; let pendingModuleOperations = 0;
const liveZstdStreamDecoders = new Set<number>();
const liveZstdStreamEncoders = new Set<number>();
export interface RgbaTransformResult { export interface RgbaTransformResult {
rgba: Uint8Array; rgba: Uint8Array;
@@ -58,6 +60,28 @@ async function loadModule(): Promise<void> {
await modulePromise; await modulePromise;
} }
function isLibfluxcoreModulePinned(): boolean {
return pendingModuleOperations > 0 || liveZstdStreamDecoders.size > 0 || liveZstdStreamEncoders.size > 0;
}
async function withPinnedLibfluxcoreModule<T>(run: () => T): Promise<T> {
pendingModuleOperations++;
try {
await loadModule();
return run();
} finally {
pendingModuleOperations--;
releaseLibfluxcoreMemoryIfIdle();
}
}
function requireLiveZstdStreamHandle(handle: number, live: Set<number>): number {
if (!live.has(handle)) {
throw new Error('libfluxcore zstd stream handle is no longer live');
}
return handle;
}
export function isLibfluxcoreReady(): boolean { export function isLibfluxcoreReady(): boolean {
return moduleReady; return moduleReady;
} }
@@ -67,7 +91,7 @@ export async function ensureLibfluxcoreReady(): Promise<void> {
} }
export function releaseLibfluxcoreMemoryIfIdle(): void { export function releaseLibfluxcoreMemoryIfIdle(): void {
if (activeZstdStreamHandles > 0) return; if (isLibfluxcoreModulePinned()) return;
if ((moduleExports?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES) return; if ((moduleExports?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES) return;
wasm.__resetLibfluxcoreWasmForMemoryPressure(); wasm.__resetLibfluxcoreWasmForMemoryPressure();
modulePromise = null; modulePromise = null;
@@ -76,13 +100,7 @@ export function releaseLibfluxcoreMemoryIfIdle(): void {
} }
export async function detectAnimatedImage(data: Uint8Array): Promise<boolean> { export async function detectAnimatedImage(data: Uint8Array): Promise<boolean> {
await loadModule(); return withPinnedLibfluxcoreModule(() => Boolean(wasm.is_animated_image(data)));
try {
const result = wasm.is_animated_image(data);
return Boolean(result);
} finally {
releaseLibfluxcoreMemoryIfIdle();
}
} }
export function cropAndRotateGif( export function cropAndRotateGif(
@@ -210,70 +228,70 @@ export async function cropAndRotateApng(
resizeWidth: number | null, resizeWidth: number | null,
resizeHeight: number | null, resizeHeight: number | null,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
await loadModule(); return withPinnedLibfluxcoreModule(() => {
try {
const result = wasm.crop_and_rotate_apng(apng, x, y, width, height, rotation, resizeWidth, resizeHeight); const result = wasm.crop_and_rotate_apng(apng, x, y, width, height, rotation, resizeWidth, resizeHeight);
return result instanceof Uint8Array ? result : new Uint8Array(result); return result instanceof Uint8Array ? result : new Uint8Array(result);
} finally { });
releaseLibfluxcoreMemoryIfIdle();
}
} }
export async function decompressZstdFrame(input: Uint8Array): Promise<Uint8Array | null> { export async function decompressZstdFrame(input: Uint8Array): Promise<Uint8Array | null> {
await loadModule(); return withPinnedLibfluxcoreModule(() => {
try {
const result = wasm.decompress_zstd_frame(input); const result = wasm.decompress_zstd_frame(input);
return result instanceof Uint8Array ? result : new Uint8Array(result); return result instanceof Uint8Array ? result : new Uint8Array(result);
} finally { });
releaseLibfluxcoreMemoryIfIdle();
}
} }
export async function createZstdStreamDecoder(): Promise<number> { export async function createZstdStreamDecoder(): Promise<number> {
await loadModule(); return withPinnedLibfluxcoreModule(() => {
const decoder = wasm.create_zstd_stream_decoder(); const decoder = wasm.create_zstd_stream_decoder();
activeZstdStreamHandles++; liveZstdStreamDecoders.add(decoder);
return decoder; return decoder;
});
} }
export async function decompressZstdStreamChunk(decoder: number, input: Uint8Array): Promise<Uint8Array> { export async function decompressZstdStreamChunk(decoder: number, input: Uint8Array): Promise<Uint8Array> {
await loadModule(); return withPinnedLibfluxcoreModule(() => {
const result = wasm.decompress_zstd_stream_chunk(decoder, input); const result = wasm.decompress_zstd_stream_chunk(
return result instanceof Uint8Array ? result : new Uint8Array(result); requireLiveZstdStreamHandle(decoder, liveZstdStreamDecoders),
input,
);
return result instanceof Uint8Array ? result : new Uint8Array(result);
});
} }
export function freeZstdStreamDecoder(decoder: number): void { export function freeZstdStreamDecoder(decoder: number): void {
if (!liveZstdStreamDecoders.delete(decoder)) return;
try { try {
wasm.free_zstd_stream_decoder(decoder); wasm.free_zstd_stream_decoder(decoder);
} finally { } finally {
activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1);
releaseLibfluxcoreMemoryIfIdle(); releaseLibfluxcoreMemoryIfIdle();
} }
} }
export async function createZstdStreamEncoder(level: number): Promise<number> { export async function createZstdStreamEncoder(level: number): Promise<number> {
await loadModule(); return withPinnedLibfluxcoreModule(() => {
const encoder = wasm.create_zstd_stream_encoder(level); const encoder = wasm.create_zstd_stream_encoder(level);
activeZstdStreamHandles++; liveZstdStreamEncoders.add(encoder);
return encoder; return encoder;
});
} }
export function createZstdStreamEncoderSync(level: number): number { export function createZstdStreamEncoderSync(level: number): number {
const encoder = wasm.create_zstd_stream_encoder(level); const encoder = wasm.create_zstd_stream_encoder(level);
activeZstdStreamHandles++; liveZstdStreamEncoders.add(encoder);
return encoder; return encoder;
} }
export function compressZstdStreamChunk(encoder: number, input: Uint8Array): Uint8Array { 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); return result instanceof Uint8Array ? result : new Uint8Array(result);
} }
export function freeZstdStreamEncoder(encoder: number): void { export function freeZstdStreamEncoder(encoder: number): void {
if (!liveZstdStreamEncoders.delete(encoder)) return;
try { try {
wasm.free_zstd_stream_encoder(encoder); wasm.free_zstd_stream_encoder(encoder);
} finally { } finally {
activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1);
releaseLibfluxcoreMemoryIfIdle(); releaseLibfluxcoreMemoryIfIdle();
} }
} }
@@ -648,6 +648,7 @@ export class User {
this._premiumType === other._premiumType && this._premiumType === other._premiumType &&
datesEqual(this._premiumSince, other._premiumSince) && datesEqual(this._premiumSince, other._premiumSince) &&
datesEqual(this._premiumUntil, other._premiumUntil) && datesEqual(this._premiumUntil, other._premiumUntil) &&
datesEqual(this._premiumGraceEndsAt, other._premiumGraceEndsAt) &&
this._premiumWillCancel === other._premiumWillCancel && this._premiumWillCancel === other._premiumWillCancel &&
this._premiumBillingCycle === other._premiumBillingCycle && this._premiumBillingCycle === other._premiumBillingCycle &&
this._premiumLifetimeSequence === other._premiumLifetimeSequence && this._premiumLifetimeSequence === other._premiumLifetimeSequence &&
+16 -3
View File
@@ -55,6 +55,7 @@ function isPublicOnlyCurrentUserPayload(user: WireUser): boolean {
class Users { class Users {
users: Record<string, User> = {}; users: Record<string, User> = {};
userCount = 0;
constructor() { constructor() {
makeAutoObservable(this, {}, {autoBind: true}); makeAutoObservable(this, {}, {autoBind: true});
@@ -98,6 +99,7 @@ class Users {
this.users = { this.users = {
[currentUser.id]: userRecord, [currentUser.id]: userRecord,
}; };
this.userCount = 1;
if (!userRecord.isClaimed()) { if (!userRecord.isClaimed()) {
setTimeout(async () => { setTimeout(async () => {
openClaimAccountModal(); openClaimAccountModal();
@@ -121,7 +123,18 @@ class Users {
) { ) {
return; 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( cacheUsers(
@@ -137,14 +150,14 @@ class Users {
if (user.id === this.currentUserId && existingUser && isPublicOnlyCurrentUserPayload(user)) { if (user.id === this.currentUserId && existingUser && isPublicOnlyCurrentUserPayload(user)) {
continue; 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 { subscribe(callback: () => void): () => void {
return reaction( return reaction(
() => Object.keys(this.users).length, () => this.userCount,
() => callback(), () => callback(),
{fireImmediately: true}, {fireImmediately: true},
); );