fix(app): stop the quick switcher list rebuilding while you navigate (#1881)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:37:27 +02:00
1 parent 9b3dc19037
commit 76ce060d13
3 files changed
+142 -27

No files matched your search

@@ -6,7 +6,6 @@ import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener';
import type {GuildMember} from '@app/features/member/models/GuildMember'; import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers'; import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch, {type SearchContext, type TransformedMember} from '@app/features/member/state/MemberSearch'; import MemberSearch, {type SearchContext, type TransformedMember} from '@app/features/member/state/MemberSearch';
import Navigation from '@app/features/navigation/state/Navigation';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel'; import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import {parseChannelUrl} from '@app/features/navigation/utils/DeepLinkUtils'; import {parseChannelUrl} from '@app/features/navigation/utils/DeepLinkUtils';
import {Logger} from '@app/features/platform/utils/AppLogger'; import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -19,7 +18,7 @@ import {
generateQueryModeResults, generateQueryModeResults,
resolveTransformedMember, resolveTransformedMember,
} from '@app/features/search/state/QuickSwitcherResultGenerators'; } from '@app/features/search/state/QuickSwitcherResultGenerators';
import {resolveRecomputedSelectedIndex} from '@app/features/search/state/QuickSwitcherSelection'; import {hasSameResultIdentity, resolveRecomputedSelectedIndex} from '@app/features/search/state/QuickSwitcherSelection';
import type { import type {
CandidateSets, CandidateSets,
ComputeResultsForQueryResult, ComputeResultsForQueryResult,
@@ -35,7 +34,7 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants'; import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
import type {I18n} from '@lingui/core'; import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {action, makeAutoObservable, reaction, runInAction} from 'mobx'; import {action, makeAutoObservable, runInAction} from 'mobx';
const GO_TO_MESSAGE_DESCRIPTOR = msg({ const GO_TO_MESSAGE_DESCRIPTOR = msg({
message: 'Go to message', message: 'Go to message',
@@ -49,6 +48,7 @@ class QuickSwitcher {
private logger = new Logger('QuickSwitcher'); private logger = new Logger('QuickSwitcher');
private candidateSets: CandidateSets | null = null; private candidateSets: CandidateSets | null = null;
private candidateWarmupCancel: (() => void) | null = null; private candidateWarmupCancel: (() => void) | null = null;
private defaultResults: Array<QuickSwitcherResult> | null = null;
private modalPreloadPromise: Promise<QuickSwitcherModalModule> | null = null; private modalPreloadPromise: Promise<QuickSwitcherModalModule> | null = null;
private modalPreloadCancel: (() => void) | null = null; private modalPreloadCancel: (() => void) | null = null;
isOpen = false; isOpen = false;
@@ -65,34 +65,24 @@ class QuickSwitcher {
constructor() { constructor() {
makeAutoObservable< makeAutoObservable<
this, this,
'candidateSets' | 'candidateWarmupCancel' | 'logger' | 'modalPreloadCancel' | 'modalPreloadPromise' | 'candidateSets'
| 'candidateWarmupCancel'
| 'defaultResults'
| 'logger'
| 'modalPreloadCancel'
| 'modalPreloadPromise'
>( >(
this, this,
{ {
candidateSets: false, candidateSets: false,
candidateWarmupCancel: false, candidateWarmupCancel: false,
defaultResults: false,
logger: false, logger: false,
modalPreloadCancel: false, modalPreloadCancel: false,
modalPreloadPromise: false, modalPreloadPromise: false,
}, },
{autoBind: true}, {autoBind: true},
); );
reaction(
() => SelectedChannel.recentChannelVisits,
() => {
if (this.isOpen) {
this.recomputeIfOpen();
}
},
);
reaction(
() => [Navigation.guildId, Navigation.channelId],
() => {
if (this.isOpen) {
this.recomputeIfOpen();
}
},
);
} }
setI18n(i18n: I18n): void { setI18n(i18n: I18n): void {
@@ -102,7 +92,8 @@ class QuickSwitcher {
private handleLocaleChange(): void { private handleLocaleChange(): void {
this.invalidateCandidateSets(); this.invalidateCandidateSets();
this.recomputeIfOpen({invalidateCandidates: false}); this.defaultResults = null;
this.recomputeIfOpen({force: true, invalidateCandidates: false});
} }
preloadModal(): void { preloadModal(): void {
@@ -171,6 +162,7 @@ class QuickSwitcher {
if (this.isOpen) return; if (this.isOpen) return;
this.cancelCandidateWarmup(); this.cancelCandidateWarmup();
this.candidateSets = null; this.candidateSets = null;
this.defaultResults = null;
this.isOpen = true; this.isOpen = true;
this.query = ''; this.query = '';
this.queryMode = null; this.queryMode = null;
@@ -231,6 +223,7 @@ class QuickSwitcher {
this.cancelCandidateWarmup(); this.cancelCandidateWarmup();
this.isOpen = false; this.isOpen = false;
this.candidateSets = null; this.candidateSets = null;
this.defaultResults = null;
this.query = ''; this.query = '';
this.queryMode = null; this.queryMode = null;
this.results = []; this.results = [];
@@ -303,6 +296,7 @@ class QuickSwitcher {
if (!this.isOpen && query.length === 0) { if (!this.isOpen && query.length === 0) {
return; return;
} }
this.defaultResults = null;
const {queryMode, results, selectedIndex} = this.computeResultsForQuery(query); const {queryMode, results, selectedIndex} = this.computeResultsForQuery(query);
this.query = query; this.query = query;
this.queryMode = queryMode; this.queryMode = queryMode;
@@ -398,7 +392,7 @@ class QuickSwitcher {
this.selectedIndex = selectedIndex; this.selectedIndex = selectedIndex;
} }
recomputeIfOpen(options: {invalidateCandidates?: boolean} = {}): void { recomputeIfOpen(options: {force?: boolean; invalidateCandidates?: boolean} = {}): void {
if (!this.isOpen) { if (!this.isOpen) {
return; return;
} }
@@ -407,11 +401,21 @@ class QuickSwitcher {
} }
const previous = this.results[this.selectedIndex]; const previous = this.results[this.selectedIndex];
const {queryMode, results, selectedIndex} = this.computeResultsForQuery(this.query); const {queryMode, results, selectedIndex} = this.computeResultsForQuery(this.query);
if (!options.force && this.queryMode === queryMode && hasSameResultIdentity(this.results, results)) {
return;
}
this.queryMode = queryMode; this.queryMode = queryMode;
this.results = results; this.results = results;
this.selectedIndex = resolveRecomputedSelectedIndex(previous, results, selectedIndex); this.selectedIndex = resolveRecomputedSelectedIndex(previous, results, selectedIndex);
} }
private getDefaultResults(i18n: I18n): Array<QuickSwitcherResult> {
if (this.defaultResults == null || this.defaultResults.length === 0) {
this.defaultResults = generateDefaultResults(i18n);
}
return this.defaultResults;
}
private computeResultsForQuery(query: string): ComputeResultsForQueryResult { private computeResultsForQuery(query: string): ComputeResultsForQueryResult {
const i18n = this.getI18n(); const i18n = this.getI18n();
const channelPath = parseChannelUrl(query); const channelPath = parseChannelUrl(query);
@@ -430,11 +434,11 @@ class QuickSwitcher {
}; };
} }
if (query['trim']().length === 0) { if (query['trim']().length === 0) {
const results = generateDefaultResults(i18n); const defaultResults = this.getDefaultResults(i18n);
return { return {
queryMode: null, queryMode: null,
results, results: defaultResults,
selectedIndex: getFirstSelectableIndex(results), selectedIndex: getFirstSelectableIndex(defaultResults),
}; };
} }
const queryMode = this.getQueryMode(query); const queryMode = this.getQueryMode(query);
@@ -445,7 +449,7 @@ class QuickSwitcher {
const sets = this.getCandidateSets(i18n); const sets = this.getCandidateSets(i18n);
results = generateQueryModeResults(queryMode, trimmedSearch, sets, i18n, this.memberSearchResults); results = generateQueryModeResults(queryMode, trimmedSearch, sets, i18n, this.memberSearchResults);
} else if (trimmedSearch.length === 0) { } else if (trimmedSearch.length === 0) {
results = generateDefaultResults(i18n); results = this.getDefaultResults(i18n);
} else { } else {
const sets = this.getCandidateSets(i18n); const sets = this.getCandidateSets(i18n);
results = generateGeneralResults(trimmedSearch, sets, i18n); results = generateGeneralResults(trimmedSearch, sets, i18n);
@@ -1,12 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRecomputedSelectedIndex} from '@app/features/search/state/QuickSwitcherSelection'; import {hasSameResultIdentity, resolveRecomputedSelectedIndex} from '@app/features/search/state/QuickSwitcherSelection';
import type {QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes'; import type {QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes';
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants'; import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
const result = (type: QuickSwitcherResult['type'], id: string) => ({type, id}) as QuickSwitcherResult; const result = (type: QuickSwitcherResult['type'], id: string) => ({type, id}) as QuickSwitcherResult;
const labelled = (type: QuickSwitcherResult['type'], id: string, title: string, subtitle?: string) =>
({type, id, title, subtitle}) as QuickSwitcherResult;
describe('resolveRecomputedSelectedIndex', () => { describe('resolveRecomputedSelectedIndex', () => {
it('follows the focused result when the list is reordered', () => { it('follows the focused result when the list is reordered', () => {
const previous = result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c3'); const previous = result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c3');
@@ -44,3 +47,86 @@ describe('resolveRecomputedSelectedIndex', () => {
expect(resolveRecomputedSelectedIndex(undefined, [result(QuickSwitcherResultTypes.GUILD, 'g')], 0)).toBe(0); expect(resolveRecomputedSelectedIndex(undefined, [result(QuickSwitcherResultTypes.GUILD, 'g')], 0)).toBe(0);
}); });
}); });
describe('hasSameResultIdentity', () => {
it('treats a rebuilt list of the same rows in the same order as unchanged', () => {
const previous = [
result(QuickSwitcherResultTypes.HEADER, 'people'),
result(QuickSwitcherResultTypes.USER, 'u1'),
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1'),
];
const next = [
result(QuickSwitcherResultTypes.HEADER, 'people'),
result(QuickSwitcherResultTypes.USER, 'u1'),
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1'),
];
expect(hasSameResultIdentity(previous, next)).toBe(true);
});
it('detects a reorder', () => {
const previous = [
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1'),
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c2'),
];
const next = [
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c2'),
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1'),
];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('detects an insertion', () => {
const previous = [result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1')];
const next = [
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1'),
result(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c2'),
];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('detects a row whose type changed under the same id', () => {
const previous = [result(QuickSwitcherResultTypes.TEXT_CHANNEL, '42')];
const next = [result(QuickSwitcherResultTypes.VOICE_CHANNEL, '42')];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('treats two empty lists as unchanged', () => {
expect(hasSameResultIdentity([], [])).toBe(true);
});
it('detects a renamed row so the open list repaints', () => {
const previous = [labelled(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1', 'general')];
const next = [labelled(QuickSwitcherResultTypes.TEXT_CHANNEL, 'c1', 'general-chat')];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('detects a changed subtitle under an unchanged title', () => {
const previous = [labelled(QuickSwitcherResultTypes.USER, 'u1', 'ada', 'ada#0001')];
const next = [labelled(QuickSwitcherResultTypes.USER, 'u1', 'ada', 'ada#0002')];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('detects a subtitle that appeared where there was none', () => {
const previous = [labelled(QuickSwitcherResultTypes.USER, 'u1', 'ada')];
const next = [labelled(QuickSwitcherResultTypes.USER, 'u1', 'ada', 'ada#0001')];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('detects a renamed section header', () => {
const previous = [labelled(QuickSwitcherResultTypes.HEADER, 'people', 'People')];
const next = [labelled(QuickSwitcherResultTypes.HEADER, 'people', 'Personnes')];
expect(hasSameResultIdentity(previous, next)).toBe(false);
});
it('still treats an identically labelled rebuild as unchanged', () => {
const previous = [
labelled(QuickSwitcherResultTypes.HEADER, 'people', 'People'),
labelled(QuickSwitcherResultTypes.USER, 'u1', 'ada', 'ada#0001'),
];
const next = [
labelled(QuickSwitcherResultTypes.HEADER, 'people', 'People'),
labelled(QuickSwitcherResultTypes.USER, 'u1', 'ada', 'ada#0001'),
];
expect(hasSameResultIdentity(previous, next)).toBe(true);
});
});
@@ -2,6 +2,31 @@
import type {QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes'; import type {QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes';
function getResultSubtitle(result: QuickSwitcherResult): string | undefined {
return 'subtitle' in result ? result.subtitle : undefined;
}
export function hasSameResultIdentity(
previous: ReadonlyArray<QuickSwitcherResult>,
next: ReadonlyArray<QuickSwitcherResult>,
): boolean {
if (previous.length !== next.length) {
return false;
}
for (let i = 0; i < previous.length; i += 1) {
if (previous[i].type !== next[i].type || previous[i].id !== next[i].id) {
return false;
}
if (previous[i].title !== next[i].title) {
return false;
}
if (getResultSubtitle(previous[i]) !== getResultSubtitle(next[i])) {
return false;
}
}
return true;
}
export function resolveRecomputedSelectedIndex( export function resolveRecomputedSelectedIndex(
previous: QuickSwitcherResult | undefined, previous: QuickSwitcherResult | undefined,
results: ReadonlyArray<QuickSwitcherResult>, results: ReadonlyArray<QuickSwitcherResult>,