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);
+};