fix(app): hold the switched view until its route commits (#3293)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:38:36 +02:00
1 parent c3d790f664
commit 0fcab2f8f0
2 files changed
+93 -7

No files matched your search

@@ -36,7 +36,12 @@ const mocks = vi.hoisted(() => ({
retireForAccountTransition: vi.fn<(reason: string) => Promise<void>>(() => Promise.resolve()), retireForAccountTransition: vi.fn<(reason: string) => Promise<void>>(() => Promise.resolve()),
gatewayLogout: vi.fn<() => void>(), gatewayLogout: vi.fn<() => void>(),
sendInvisiblePresence: vi.fn<() => void>(), sendInvisiblePresence: vi.fn<() => void>(),
replaceWith: vi.fn<(path: string) => void>(), replaceWith: vi.fn<(path: string) => void>((path) => {
mocks.currentPath = path;
mocks.commitRouterPath(path);
}),
readRouterPath: (): string => '',
commitRouterPath: (_path: string): void => undefined,
abandonUnreachableLastLocation: vi.fn<() => void>(), abandonUnreachableLastLocation: vi.fn<() => void>(),
currentPath: '', currentPath: '',
lastLocation: null as string | null, lastLocation: null as string | null,
@@ -80,6 +85,20 @@ vi.mock('@app/features/navigation/utils/RouterUtils', () => ({
history: null, history: null,
})); }));
vi.mock('@app/features/navigation/state/Navigation', async () => {
const {observable, runInAction} = await import('mobx');
const routerPath = observable.box('');
mocks.readRouterPath = () => routerPath.get();
mocks.commitRouterPath = (path) => runInAction(() => routerPath.set(path));
return {
default: {
get pathname() {
return mocks.readRouterPath();
},
},
};
});
vi.mock('@app/features/ui/state/Location', () => ({ vi.mock('@app/features/ui/state/Location', () => ({
default: {getLastLocation: () => mocks.lastLocation, saveLocation: mocks.saveLocation}, default: {getLastLocation: () => mocks.lastLocation, saveLocation: mocks.saveLocation},
})); }));
@@ -203,9 +222,11 @@ function gatewayOutage(accountKey: string): InstanceType<typeof ForegroundGatewa
beforeEach(async () => { beforeEach(async () => {
mocks.currentPath = ''; mocks.currentPath = '';
mocks.commitRouterPath('');
mocks.lastLocation = null; mocks.lastLocation = null;
mocks.replaceWith.mockImplementation((path) => { mocks.replaceWith.mockImplementation((path) => {
mocks.currentPath = path; mocks.currentPath = path;
mocks.commitRouterPath(path);
}); });
mocks.foregroundAccountKey = null; mocks.foregroundAccountKey = null;
mocks.hydrateForegroundStores(null); mocks.hydrateForegroundStores(null);
@@ -490,9 +511,11 @@ describe('Accounts view handover between accounts that share a guild', () => {
mocks.currentPath = GENERAL_ROUTE; mocks.currentPath = GENERAL_ROUTE;
mocks.lastLocation = GENERAL_ROUTE; mocks.lastLocation = GENERAL_ROUTE;
runInAction(() => routedPath.set(GENERAL_ROUTE)); runInAction(() => routedPath.set(GENERAL_ROUTE));
mocks.commitRouterPath(GENERAL_ROUTE);
mocks.replaceWith.mockImplementation((path) => { mocks.replaceWith.mockImplementation((path) => {
mocks.currentPath = path; mocks.currentPath = path;
runInAction(() => routedPath.set(path)); runInAction(() => routedPath.set(path));
mocks.commitRouterPath(path);
redirectUnknownSharedGuildChannel(path); redirectUnknownSharedGuildChannel(path);
}); });
mocks.saveLocation.mockImplementation((path) => { mocks.saveLocation.mockImplementation((path) => {
@@ -581,6 +604,36 @@ describe('Accounts view handover between accounts that share a guild', () => {
expect(rendered.filter((entry) => !entry.live).every((entry) => entry.path === GENERAL_ROUTE)).toBe(true); expect(rendered.filter((entry) => !entry.live).every((entry) => entry.path === GENERAL_ROUTE)).toBe(true);
}); });
test('the target view stays held until the router commits the target route', async () => {
mocks.replaceWith.mockImplementation((path) => {
mocks.currentPath = path;
setTimeout(() => {
runInAction(() => routedPath.set(path));
mocks.commitRouterPath(path);
}, 0);
});
const rendered: Array<{accountKey: string | null; path: string; live: boolean}> = [];
const ViewRecorder = observer(() => {
rendered.push({accountKey: SessionManager.currentAccountKey, path: routedPath.get(), live: Accounts.isViewLive});
return null;
});
const container = document.createElement('div');
const root = createRoot(container);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
act(() => root.render(createElement(ViewRecorder)));
disposers.push(() => act(() => root.unmount()));
await act(async () => {
await Accounts.switchToAccount(accountB);
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(
rendered.filter((entry) => entry.live && entry.accountKey === accountB && entry.path !== PRIVATE_ROUTE),
).toEqual([]);
expect(rendered.at(-1)).toEqual({accountKey: accountB, path: PRIVATE_ROUTE, live: true});
});
test('the target route is in place in the same tick as its state, before the gateway handshake settles', async () => { test('the target route is in place in the same tick as its state, before the gateway handshake settles', async () => {
await Accounts.switchToAccount(accountB); await Accounts.switchToAccount(accountB);
+39 -6
View File
@@ -17,6 +17,7 @@ import {getAccountKey, parseAccountStorageKey} from '@app/features/auth/state/Ac
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments'; import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {ForegroundGatewayConnectionRecoverableError} from '@app/features/gateway/transport/ForegroundGatewayConnectionFailure'; import {ForegroundGatewayConnectionRecoverableError} from '@app/features/gateway/transport/ForegroundGatewayConnectionFailure';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import Navigation from '@app/features/navigation/state/Navigation';
import {abandonUnreachableLastLocation} from '@app/features/navigation/utils/ChannelRouteReachability'; import {abandonUnreachableLastLocation} from '@app/features/navigation/utils/ChannelRouteReachability';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils'; import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils'; import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
@@ -103,7 +104,10 @@ interface AccountReplacementCommitRequest {
type AccountViewReplacement = type AccountViewReplacement =
| {readonly kind: 'activation'; readonly redirectPath: string | null} | {readonly kind: 'activation'; readonly redirectPath: string | null}
| {readonly kind: 'rollback'; readonly previousRoute: string}; | {readonly kind: 'rollback'; readonly previousRoute: string}
| {readonly kind: 'navigating'; readonly stalePath: string};
const VIEW_NAVIGATION_COMMIT_TIMEOUT_MS = 2000;
type AccountActivationOutcome = type AccountActivationOutcome =
| {readonly kind: 'ready'} | {readonly kind: 'ready'}
@@ -478,9 +482,11 @@ class Accounts {
throw activationError; throw activationError;
} finally { } finally {
stopAwaitingLiveView(); stopAwaitingLiveView();
if (this.viewReplacement?.kind !== 'navigating') {
this.viewReplacement = null; this.viewReplacement = null;
} }
} }
}
private beginViewReplacement(request: AccountReplacementCommitRequest, previous: PreviousAccountState): () => void { private beginViewReplacement(request: AccountReplacementCommitRequest, previous: PreviousAccountState): () => void {
this.viewReplacement = {kind: 'activation', redirectPath: request.redirectPath}; this.viewReplacement = {kind: 'activation', redirectPath: request.redirectPath};
@@ -612,17 +618,44 @@ class Accounts {
private revealReplacementView(): void { private revealReplacementView(): void {
const replacement = this.viewReplacement; const replacement = this.viewReplacement;
if (replacement === null) { if (replacement === null || replacement.kind === 'navigating') {
return; return;
} }
const path = this.resolveReplacementViewPath(replacement); const path = this.resolveReplacementViewPath(replacement);
if (path !== null && path !== RouterUtils.getCurrentPath()) { const stalePath = Navigation.pathname;
RouterUtils.replaceWith(path); if (path === null || path === RouterUtils.getCurrentPath()) {
}
this.viewReplacement = null; this.viewReplacement = null;
return;
}
RouterUtils.replaceWith(path);
this.holdViewUntilNavigationCommits(stalePath);
} }
private resolveReplacementViewPath(replacement: AccountViewReplacement): string | null { private holdViewUntilNavigationCommits(stalePath: string): void {
if (Navigation.pathname !== stalePath) {
this.viewReplacement = null;
return;
}
const marker: AccountViewReplacement = {kind: 'navigating', stalePath};
this.viewReplacement = marker;
const release = () => {
if (this.viewReplacement === marker) {
this.viewReplacement = null;
}
};
const timeout = setTimeout(release, VIEW_NAVIGATION_COMMIT_TIMEOUT_MS);
when(
() => this.viewReplacement !== marker || Navigation.pathname !== stalePath,
() => {
clearTimeout(timeout);
release();
},
);
}
private resolveReplacementViewPath(
replacement: Exclude<AccountViewReplacement, {readonly kind: 'navigating'}>,
): string | null {
if (replacement.kind === 'rollback') { if (replacement.kind === 'rollback') {
return replacement.previousRoute === '' ? null : replacement.previousRoute; return replacement.previousRoute === '' ? null : replacement.previousRoute;
} }