diff --git a/fluxer_app/src/features/auth/state/Accounts.test.ts b/fluxer_app/src/features/auth/state/Accounts.test.ts index f35a4e769..0441bb3b6 100644 --- a/fluxer_app/src/features/auth/state/Accounts.test.ts +++ b/fluxer_app/src/features/auth/state/Accounts.test.ts @@ -36,7 +36,12 @@ const mocks = vi.hoisted(() => ({ retireForAccountTransition: vi.fn<(reason: string) => Promise>(() => Promise.resolve()), gatewayLogout: 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>(), currentPath: '', lastLocation: null as string | null, @@ -80,6 +85,20 @@ vi.mock('@app/features/navigation/utils/RouterUtils', () => ({ 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', () => ({ default: {getLastLocation: () => mocks.lastLocation, saveLocation: mocks.saveLocation}, })); @@ -203,9 +222,11 @@ function gatewayOutage(accountKey: string): InstanceType { mocks.currentPath = ''; + mocks.commitRouterPath(''); mocks.lastLocation = null; mocks.replaceWith.mockImplementation((path) => { mocks.currentPath = path; + mocks.commitRouterPath(path); }); mocks.foregroundAccountKey = null; mocks.hydrateForegroundStores(null); @@ -490,9 +511,11 @@ describe('Accounts view handover between accounts that share a guild', () => { mocks.currentPath = GENERAL_ROUTE; mocks.lastLocation = GENERAL_ROUTE; runInAction(() => routedPath.set(GENERAL_ROUTE)); + mocks.commitRouterPath(GENERAL_ROUTE); mocks.replaceWith.mockImplementation((path) => { mocks.currentPath = path; runInAction(() => routedPath.set(path)); + mocks.commitRouterPath(path); redirectUnknownSharedGuildChannel(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); }); + 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 () => { await Accounts.switchToAccount(accountB); diff --git a/fluxer_app/src/features/auth/state/Accounts.ts b/fluxer_app/src/features/auth/state/Accounts.ts index e3ac86111..b1bbe6255 100644 --- a/fluxer_app/src/features/auth/state/Accounts.ts +++ b/fluxer_app/src/features/auth/state/Accounts.ts @@ -17,6 +17,7 @@ import {getAccountKey, parseAccountStorageKey} from '@app/features/auth/state/Ac import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments'; import {ForegroundGatewayConnectionRecoverableError} from '@app/features/gateway/transport/ForegroundGatewayConnectionFailure'; 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 * as RouterUtils from '@app/features/navigation/utils/RouterUtils'; import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils'; @@ -103,7 +104,10 @@ interface AccountReplacementCommitRequest { type AccountViewReplacement = | {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 = | {readonly kind: 'ready'} @@ -478,7 +482,9 @@ class Accounts { throw activationError; } finally { stopAwaitingLiveView(); - this.viewReplacement = null; + if (this.viewReplacement?.kind !== 'navigating') { + this.viewReplacement = null; + } } } @@ -612,17 +618,44 @@ class Accounts { private revealReplacementView(): void { const replacement = this.viewReplacement; - if (replacement === null) { + if (replacement === null || replacement.kind === 'navigating') { return; } const path = this.resolveReplacementViewPath(replacement); - if (path !== null && path !== RouterUtils.getCurrentPath()) { - RouterUtils.replaceWith(path); + const stalePath = Navigation.pathname; + if (path === null || path === RouterUtils.getCurrentPath()) { + this.viewReplacement = null; + return; } - this.viewReplacement = null; + 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, + ): string | null { if (replacement.kind === 'rollback') { return replacement.previousRoute === '' ? null : replacement.previousRoute; }