fix(nav): collapse push-tap, tab and back-arrow navigations into the back stack via replace

This commit is contained in:
heaven 2026-04-26 00:35:26 +03:00
parent dbda8728a8
commit 1c079ddca2
10 changed files with 97 additions and 33 deletions

View file

@ -10,8 +10,11 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const location = useLocation();
const goBack = useCallback(() => {
// Back arrow walks up the route tree to the section root — semantically
// a "go up", not a "push parent". Replacing keeps the back-stack from
// growing when the user repeatedly taps back on nested screens.
const parent = getRouteSectionParent(location.pathname);
if (parent) navigate(parent);
if (parent) navigate(parent, { replace: true });
}, [navigate, location.pathname]);
return children(goBack);

View file

@ -45,7 +45,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
const callKey = getIncomingCallKey(call.callId, call.roomId);
const handleAnswer = () => {
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)));
// Mirror the native CallStyle Answer path (usePushNotifications
// pushNotificationActionPerformed handler): in-app strip Answer is the
// same semantic action — switch to the call room — so it should not
// grow the back stack either.
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)), { replace: true });
switchOrStartDmCall(call.roomId)
.then(() => {
const evId = call.notifEvent.getId();

View file

@ -11,9 +11,9 @@ const EXIT_CONFIRM_WINDOW_MS = 2000;
/**
* Maps the Android hardware back button to web-equivalent behavior, using
* an app-level back stack instead of the WebView's native history. The
* WebView's canGoBack includes redirects / replaces / tab-switch entries
* that inflate its depth, which is why we maintain our own stack keyed
* off react-router's useNavigationType.
* WebView's canGoBack counts redirects and `replace` calls as separate
* entries, so we cannot trust its depth keep our own stack keyed off
* react-router's useNavigationType.
*
* 1. Overlay/modal open dispatch Escape (closes folds Overlay via
* focus-trap-react's escapeDeactivates).
@ -29,6 +29,11 @@ const EXIT_CONFIRM_WINDOW_MS = 2000;
* second press within EXIT_CONFIRM_WINDOW_MS to exit; first press
* shows a native Android toast. Any navigation between presses
* resets the window so the prompt can't leak across screens.
*
* Stack-bloat sources are addressed at the navigation site (push-tap and
* sidebar tabs use `replace`, useRoomNavigate auto-replaces same-pathname
* navs) keeping this tracker free of dedup logic that would otherwise
* have to reconcile against phantom router-history entries.
*/
export const useAndroidBackButton = (): void => {
const navigate = useNavigate();

View file

@ -298,11 +298,15 @@ export function usePushNotificationsLifecycle(): void {
const detail = (ev as CustomEvent).detail as
| { roomId?: string; isInvite?: boolean }
| undefined;
// Push-tap navigations are "switch to this", not "stack on top of where
// I was". Without replace, N notifications from the same chat plus tab
// hops accumulate as N+ entries in our app back-stack (see
// useAndroidBackButton) — user presses back many times to exit one chat.
if (detail?.isInvite) {
navigate(getInboxInvitesPath());
navigate(getInboxInvitesPath(), { replace: true });
return;
}
if (detail?.roomId) navigateRoom(detail.roomId);
if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
};
const onSubChange = () => {
if (isPushEnabled()) register().catch(noop);
@ -350,7 +354,7 @@ export function usePushNotificationsLifecycle(): void {
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
// it doesn't have in its left-rail, hence the DM path here.
if (data.call_action === 'answer' && data.room_id) {
navigate(getDirectRoomPath(data.room_id));
navigate(getDirectRoomPath(data.room_id), { replace: true });
setPendingCallAction({
kind: 'answer',
roomId: data.room_id,
@ -381,11 +385,11 @@ export function usePushNotificationsLifecycle(): void {
// carry notif_event_id and this branch will route them to the DM
// tab incorrectly — revisit together with the group-call pipeline.
if (data.room_id && data.notif_event_id) {
navigate(getDirectRoomPath(data.room_id));
navigate(getDirectRoomPath(data.room_id), { replace: true });
return;
}
if (data.room_id) navigateRoom(data.room_id);
if (data.room_id) navigateRoom(data.room_id, undefined, { replace: true });
}
);
})

View file

@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { NavigateOptions, useNavigate } from 'react-router-dom';
import { useCallback, useRef } from 'react';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import {
@ -18,18 +18,51 @@ import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => {
const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
// navigate(samePath) creates a fresh history entry in react-router-v6 even
// though the screen does not change — repeated taps on the same chat from
// search/inbox/notifications would inflate the back-stack tracked by
// useAndroidBackButton. Force replace when the target matches the current
// pathname so callers do not need to pass it everywhere.
//
// Pathname tracking has two sources:
// 1. useLocation → pathnameRef on every render, for consistency with the
// router after it commits a navigation we did not initiate.
// 2. Optimistic write inside safeNavigate, so two calls in the same tick
// (before React commits the first) collapse correctly: the second sees
// the target the first just navigated to and forces replace.
// Without (2), two simultaneous navigateRoom(samePath) calls — e.g. two
// notifications dispatched back-to-back, or a double-clicked list item —
// both compare against the pre-navigation pathname and both PUSH. Without
// (1), an external navigate() between our calls would leave pathnameRef
// ahead of the actual route.
//
// The callback identity stays stable (deps = [navigate] only) so consumers
// that wrap navigateRoom in their own useCallback do not capture a stale
// closure on every route change.
const pathnameRef = useRef(location.pathname);
pathnameRef.current = location.pathname;
const safeNavigate = useCallback(
(target: string, opts?: NavigateOptions) => {
const finalOpts = pathnameRef.current === target ? { ...opts, replace: true } : opts;
navigate(target, finalOpts);
pathnameRef.current = target;
},
[navigate]
);
const navigateSpace = useCallback(
(roomId: string) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
navigate(getSpacePath(roomIdOrAlias));
safeNavigate(getSpacePath(roomIdOrAlias));
},
[mx, navigate]
[mx, safeNavigate]
);
const navigateRoom = useCallback(
@ -48,7 +81,7 @@ export const useRoomNavigate = () => {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
navigate(
safeNavigate(
getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
opts
);
@ -56,13 +89,13 @@ export const useRoomNavigate = () => {
}
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
safeNavigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
[mx, safeNavigate, spaceSelectedId, roomToParents, mDirects, developerTools]
);
return {

View file

@ -10,6 +10,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getDirectPath, joinPathComponent } from '../../pathUtils';
import { isNativePlatform } from '../../../utils/capacitor';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
@ -77,13 +78,14 @@ export function DirectTab() {
const directSelected = useDirectSelected();
const handleDirectClick = () => {
const navOpts = { replace: isNativePlatform() };
const activePath = navToActivePath.get('direct');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
navigate(joinPathComponent(activePath), navOpts);
return;
}
navigate(getDirectPath());
navigate(getDirectPath(), navOpts);
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {

View file

@ -16,6 +16,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdServer } from '../../../utils/matrix';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { isNativePlatform } from '../../../utils/capacitor';
export function ExploreTab() {
const { t } = useTranslation();
@ -28,28 +29,29 @@ export function ExploreTab() {
const exploreSelected = useExploreSelected();
const handleExploreClick = () => {
const navOpts = { replace: isNativePlatform() };
if (screenSize === ScreenSize.Mobile) {
navigate(getExplorePath());
navigate(getExplorePath(), navOpts);
return;
}
const activePath = navToActivePath.get('explore');
if (activePath) {
navigate(joinPathComponent(activePath));
navigate(joinPathComponent(activePath), navOpts);
return;
}
if (clientConfig.featuredCommunities?.openAsDefault) {
navigate(getExploreFeaturedPath());
navigate(getExploreFeaturedPath(), navOpts);
return;
}
const userId = mx.getUserId();
const userServer = userId ? getMxIdServer(userId) : undefined;
if (userServer) {
navigate(getExploreServerPath(userServer));
navigate(getExploreServerPath(userServer), navOpts);
return;
}
navigate(getExplorePath());
navigate(getExplorePath(), navOpts);
};
return (

View file

@ -11,6 +11,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getHomePath, joinPathComponent } from '../../pathUtils';
import { isNativePlatform } from '../../../utils/capacitor';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
@ -78,13 +79,19 @@ export function HomeTab() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleHomeClick = () => {
// On native, tab tap is "switch tab", not "stack on top" — replace keeps
// the app back-stack (see useAndroidBackButton) reflecting actual content
// navigation, not tab toggles. Same in Direct/Inbox/Explore/Space tabs.
// On web we keep PUSH so browser-back behaves the way desktop users
// expect: back undoes the tab switch.
const navOpts = { replace: isNativePlatform() };
const activePath = navToActivePath.get('home');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
navigate(joinPathComponent(activePath), navOpts);
return;
}
navigate(getHomePath());
navigate(getHomePath(), navOpts);
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {

View file

@ -20,6 +20,7 @@ import { useInboxSelected } from '../../../hooks/router/useInbox';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { isNativePlatform } from '../../../utils/capacitor';
export function InboxTab() {
const { t } = useTranslation();
@ -31,18 +32,19 @@ export function InboxTab() {
const inviteCount = allInvites.length;
const handleInboxClick = () => {
const navOpts = { replace: isNativePlatform() };
if (screenSize === ScreenSize.Mobile) {
navigate(getInboxPath());
navigate(getInboxPath(), navOpts);
return;
}
const activePath = navToActivePath.get('inbox');
if (activePath) {
navigate(joinPathComponent(activePath));
navigate(joinPathComponent(activePath), navOpts);
return;
}
const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath();
navigate(path);
navigate(path, navOpts);
};
return (

View file

@ -62,6 +62,7 @@ import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/Roo
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
import { UnreadBadge } from '../../../components/unread-badge';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { isNativePlatform } from '../../../utils/capacitor';
import { RoomAvatar } from '../../../components/room-avatar';
import { nameInitials, randomStr } from '../../../utils/common';
import {
@ -760,18 +761,19 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
if (!targetSpaceId) return;
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
const navOpts = { replace: isNativePlatform() };
if (screenSize === ScreenSize.Mobile) {
navigate(spacePath);
navigate(spacePath, navOpts);
return;
}
const activePath = navToActivePath.get(targetSpaceId);
if (activePath && activePath.pathname.startsWith(spacePath)) {
navigate(joinPathComponent(activePath));
navigate(joinPathComponent(activePath), navOpts);
return;
}
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)), navOpts);
};
const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {