mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): hold the switched view until its route commits (#3293)
This commit is contained in:
1 parent
c3d790f664
commit
0fcab2f8f0
2 files changed
+94
-8
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +482,9 @@ class Accounts {
|
|||||||
throw activationError;
|
throw activationError;
|
||||||
} finally {
|
} finally {
|
||||||
stopAwaitingLiveView();
|
stopAwaitingLiveView();
|
||||||
this.viewReplacement = null;
|
if (this.viewReplacement?.kind !== 'navigating') {
|
||||||
|
this.viewReplacement = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
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<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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user