diff --git a/fluxer_app/rust/libfluxcore/Cargo.lock b/fluxer_app/rust/libfluxcore/Cargo.lock index 25f599388..d8408b781 100644 --- a/fluxer_app/rust/libfluxcore/Cargo.lock +++ b/fluxer_app/rust/libfluxcore/Cargo.lock @@ -53,12 +53,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.4" @@ -111,7 +105,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "r-efi 5.3.0", "wasip2", @@ -123,7 +117,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "r-efi 6.0.0", "wasip2", @@ -203,7 +197,6 @@ version = "0.1.0" dependencies = [ "proptest", "wasm-bindgen", - "wee_alloc", "zstd", ] @@ -225,12 +218,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memory_units" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" - [[package]] name = "num-traits" version = "0.2.19" @@ -530,7 +517,7 @@ version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -603,40 +590,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wee_alloc" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" -dependencies = [ - "cfg-if 0.1.10", - "libc", - "memory_units", - "winapi", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/fluxer_app/rust/libfluxcore/Cargo.toml b/fluxer_app/rust/libfluxcore/Cargo.toml index ee3f73330..25a90e7f2 100644 --- a/fluxer_app/rust/libfluxcore/Cargo.toml +++ b/fluxer_app/rust/libfluxcore/Cargo.toml @@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] wasm-bindgen = "=0.2.123" -wee_alloc = {version = "0.4.5", default-features = false} zstd = {version = "0.13.3", default-features = false, features = ["no_asm", "thin", "wasm"]} [dev-dependencies] diff --git a/fluxer_app/rust/libfluxcore/src/lib.rs b/fluxer_app/rust/libfluxcore/src/lib.rs index 45902ed48..0e332b7af 100644 --- a/fluxer_app/rust/libfluxcore/src/lib.rs +++ b/fluxer_app/rust/libfluxcore/src/lib.rs @@ -9,10 +9,6 @@ use formats::is_animated_image_bytes; use rgba::{TransformRequest, crop_rotate_rgba_alloc}; use wasm_bindgen::prelude::*; -#[cfg(target_arch = "wasm32")] -#[global_allocator] -static WASM_ALLOCATOR: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; - #[wasm_bindgen] #[allow(clippy::too_many_arguments)] pub fn crop_rotate_rgba_raw( diff --git a/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx b/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx index 6ce284c5a..f118f066f 100644 --- a/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx +++ b/fluxer_app/src/features/app/hooks/useRovingFocusList.test.tsx @@ -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(); + }); + 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( diff --git a/fluxer_app/src/features/app/hooks/useRovingFocusList.ts b/fluxer_app/src/features/app/hooks/useRovingFocusList.ts index 24a0adc72..f815a11df 100644 --- a/fluxer_app/src/features/app/hooks/useRovingFocusList.ts +++ b/fluxer_app/src/features/app/hooks/useRovingFocusList.ts @@ -72,7 +72,9 @@ const applyTabIndices = (focusable: Array, 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); } diff --git a/fluxer_app/src/features/channel/state/ChannelDisplayName.ts b/fluxer_app/src/features/channel/state/ChannelDisplayName.ts index d5039d82e..f7515b827 100644 --- a/fluxer_app/src/features/channel/state/ChannelDisplayName.ts +++ b/fluxer_app/src/features/channel/state/ChannelDisplayName.ts @@ -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(), ); diff --git a/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.test.ts b/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.test.ts new file mode 100644 index 000000000..ea2261ee5 --- /dev/null +++ b/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.test.ts @@ -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>): ReadonlyMap => + new Map(Object.entries(entries).map(([id, recipientIds]) => [id, {recipientIds}])); + +const userTable = (count: number): Record => { + const users: Record = {}; + 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, +): {proxy: Record; reads: Array} => { + const reads: Array = []; + 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 = {}; + 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 = { + a: {username: 'x", "y', globalName: null}, + b: {username: '', globalName: null}, + }; + const collidingRight: Record = { + 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>({ + 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(); + }); +}); diff --git a/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts b/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts new file mode 100644 index 000000000..4880baf6f --- /dev/null +++ b/fluxer_app/src/features/channel/state/ChannelDisplayNameTracking.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface TrackedRecipientSnapshot { + readonly recipientIds: ReadonlyArray; +} + +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, + users: Record, +): 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); +};