mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(app): stop unbounded WASM heap growth and idle-CPU churn (#1721)
This commit is contained in:
1 parent
fa71eb8682
commit
9e89539f0a
8 files changed
+205
-62
No files matched your search
@@ -152,6 +152,38 @@ describe('useRovingFocusList', () => {
|
||||
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(
|
||||
|
||||
@@ -72,7 +72,9 @@ const applyTabIndices = (focusable: Array<HTMLElement>, activeIndex: number): vo
|
||||
for (let i = 0; i < focusable.length; i++) {
|
||||
const element = focusable[i];
|
||||
const desired = i === clamped ? '0' : '-1';
|
||||
element.setAttribute(ROVING_MANAGED_ATTR, '');
|
||||
if (!element.hasAttribute(ROVING_MANAGED_ATTR)) {
|
||||
element.setAttribute(ROVING_MANAGED_ATTR, '');
|
||||
}
|
||||
if (element.getAttribute('tabindex') !== desired) {
|
||||
element.setAttribute('tabindex', desired);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {trackedRecipientNameKey} from '@app/features/channel/state/ChannelDisplayNameTracking';
|
||||
import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener';
|
||||
import {deferUntilModulesLoaded} from '@app/features/platform/utils/DeferUntilModulesLoaded';
|
||||
import {noteText} from '@app/features/theme/fonts/ScriptFontLoader';
|
||||
@@ -48,12 +49,8 @@ class ChannelDisplayName {
|
||||
deferUntilModulesLoaded(() => {
|
||||
reaction(
|
||||
() => {
|
||||
if (!Users) return [];
|
||||
return Users.usersList.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName,
|
||||
}));
|
||||
if (!Users) return '';
|
||||
return trackedRecipientNameKey(this.channelSnapshots, Users.users);
|
||||
},
|
||||
() => this.recomputeAll(),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type TrackedRecipientSnapshot,
|
||||
type TrackedRecipientUser,
|
||||
trackedRecipientNameKey,
|
||||
} from '@app/features/channel/state/ChannelDisplayNameTracking';
|
||||
import {autorun, observable, runInAction} from 'mobx';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const snapshots = (entries: Record<string, ReadonlyArray<string>>): ReadonlyMap<string, TrackedRecipientSnapshot> =>
|
||||
new Map(Object.entries(entries).map(([id, recipientIds]) => [id, {recipientIds}]));
|
||||
|
||||
const userTable = (count: number): Record<string, TrackedRecipientUser> => {
|
||||
const users: Record<string, TrackedRecipientUser> = {};
|
||||
for (let i = 0; i < count; i++) {
|
||||
users[`u${i}`] = {username: `user${i}`, globalName: null};
|
||||
}
|
||||
return users;
|
||||
};
|
||||
|
||||
/** Wraps a user table so we can count exactly which keys get read. */
|
||||
const countingUsers = (
|
||||
users: Record<string, TrackedRecipientUser | undefined>,
|
||||
): {proxy: Record<string, TrackedRecipientUser | undefined>; reads: Array<string>} => {
|
||||
const reads: Array<string> = [];
|
||||
const proxy = new Proxy(users, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === 'string') reads.push(key);
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
});
|
||||
return {proxy, reads};
|
||||
};
|
||||
|
||||
describe('trackedRecipientNameKey', () => {
|
||||
it('reads only the recipients of tracked group DMs, not the whole user cache', () => {
|
||||
const {proxy, reads} = countingUsers(userTable(10_000));
|
||||
trackedRecipientNameKey(snapshots({c1: ['u1', 'u2'], c2: ['u3']}), proxy);
|
||||
|
||||
// The cost must be a function of tracked recipients, never of cache size.
|
||||
expect(reads).toEqual(['u1', 'u2', 'u3']);
|
||||
});
|
||||
|
||||
it('cost is independent of how large the user cache grows', () => {
|
||||
const small = countingUsers(userTable(10));
|
||||
const huge = countingUsers(userTable(100_000));
|
||||
const tracked = snapshots({c1: ['u1', 'u2']});
|
||||
|
||||
trackedRecipientNameKey(tracked, small.proxy);
|
||||
trackedRecipientNameKey(tracked, huge.proxy);
|
||||
|
||||
expect(small.reads.length).toBe(2);
|
||||
expect(huge.reads.length).toBe(2);
|
||||
});
|
||||
|
||||
it('changes when a tracked recipient is renamed', () => {
|
||||
const users = userTable(3);
|
||||
const tracked = snapshots({c1: ['u1']});
|
||||
const before = trackedRecipientNameKey(tracked, users);
|
||||
|
||||
users.u1 = {username: 'renamed', globalName: null};
|
||||
|
||||
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
|
||||
});
|
||||
|
||||
it('does not change when an untracked user is renamed', () => {
|
||||
const users = userTable(3);
|
||||
const tracked = snapshots({c1: ['u1']});
|
||||
const before = trackedRecipientNameKey(tracked, users);
|
||||
|
||||
users.u2 = {username: 'unrelated-change', globalName: 'also changed'};
|
||||
|
||||
expect(trackedRecipientNameKey(tracked, users)).toBe(before);
|
||||
});
|
||||
|
||||
it('distinguishes a missing recipient from one that arrives later', () => {
|
||||
const users: Record<string, TrackedRecipientUser | undefined> = {};
|
||||
const tracked = snapshots({c1: ['u1']});
|
||||
const before = trackedRecipientNameKey(tracked, users);
|
||||
|
||||
users.u1 = {username: 'arrived', globalName: null};
|
||||
|
||||
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
|
||||
});
|
||||
|
||||
it('is not confused by usernames containing the separator characters', () => {
|
||||
const tracked = snapshots({c1: ['a', 'b']});
|
||||
const collidingLeft: Record<string, TrackedRecipientUser> = {
|
||||
a: {username: 'x", "y', globalName: null},
|
||||
b: {username: '', globalName: null},
|
||||
};
|
||||
const collidingRight: Record<string, TrackedRecipientUser> = {
|
||||
a: {username: '', globalName: null},
|
||||
b: {username: 'x", "y', globalName: null},
|
||||
};
|
||||
|
||||
expect(trackedRecipientNameKey(tracked, collidingLeft)).not.toBe(trackedRecipientNameKey(tracked, collidingRight));
|
||||
});
|
||||
|
||||
it('drives a MobX reaction on tracked renames but stays quiet on untracked ones', () => {
|
||||
const users = observable.object<Record<string, TrackedRecipientUser | undefined>>({
|
||||
u1: {username: 'one', globalName: null},
|
||||
u2: {username: 'two', globalName: null},
|
||||
});
|
||||
const tracked = snapshots({c1: ['u1']});
|
||||
|
||||
let runs = 0;
|
||||
const dispose = autorun(() => {
|
||||
trackedRecipientNameKey(tracked, users);
|
||||
runs++;
|
||||
});
|
||||
expect(runs).toBe(1);
|
||||
|
||||
// An untracked user changing must not wake the reaction.
|
||||
runInAction(() => {
|
||||
users.u2 = {username: 'two-renamed', globalName: null};
|
||||
});
|
||||
expect(runs).toBe(1);
|
||||
|
||||
// A tracked user changing must.
|
||||
runInAction(() => {
|
||||
users.u1 = {username: 'one-renamed', globalName: null};
|
||||
});
|
||||
expect(runs).toBe(2);
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface TrackedRecipientSnapshot {
|
||||
readonly recipientIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface TrackedRecipientUser {
|
||||
readonly username: string;
|
||||
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<string, TrackedRecipientSnapshot>,
|
||||
users: Record<string, TrackedRecipientUser | undefined>,
|
||||
): string => {
|
||||
const parts: Array<[string, string, string]> = [];
|
||||
for (const snapshot of snapshots.values()) {
|
||||
for (const recipientId of snapshot.recipientIds) {
|
||||
const user = users[recipientId];
|
||||
parts.push([recipientId, user?.username ?? '', user?.globalName ?? '']);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(parts);
|
||||
};
|
||||
Reference in new issue
Block a user