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

This commit is contained in:
v.lagerev 2026-04-26 00:35:26 +03:00
parent c46684800c
commit dce6be949a
10 changed files with 97 additions and 33 deletions

View file

@ -10,8 +10,11 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const location = useLocation(); const location = useLocation();
const goBack = useCallback(() => { 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); const parent = getRouteSectionParent(location.pathname);
if (parent) navigate(parent); if (parent) navigate(parent, { replace: true });
}, [navigate, location.pathname]); }, [navigate, location.pathname]);
return children(goBack); return children(goBack);

View file

@ -45,7 +45,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
const callKey = getIncomingCallKey(call.callId, call.roomId); const callKey = getIncomingCallKey(call.callId, call.roomId);
const handleAnswer = () => { 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) switchOrStartDmCall(call.roomId)
.then(() => { .then(() => {
const evId = call.notifEvent.getId(); 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 * Maps the Android hardware back button to web-equivalent behavior, using
* an app-level back stack instead of the WebView's native history. The * an app-level back stack instead of the WebView's native history. The
* WebView's canGoBack includes redirects / replaces / tab-switch entries * WebView's canGoBack counts redirects and `replace` calls as separate
* that inflate its depth, which is why we maintain our own stack keyed * entries, so we cannot trust its depth keep our own stack keyed off
* off react-router's useNavigationType. * react-router's useNavigationType.
* *
* 1. Overlay/modal open dispatch Escape (closes folds Overlay via * 1. Overlay/modal open dispatch Escape (closes folds Overlay via
* focus-trap-react's escapeDeactivates). * 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 * second press within EXIT_CONFIRM_WINDOW_MS to exit; first press
* shows a native Android toast. Any navigation between presses * shows a native Android toast. Any navigation between presses
* resets the window so the prompt can't leak across screens. * 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 => { export const useAndroidBackButton = (): void => {
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -298,11 +298,15 @@ export function usePushNotificationsLifecycle(): void {
const detail = (ev as CustomEvent).detail as const detail = (ev as CustomEvent).detail as
| { roomId?: string; isInvite?: boolean } | { roomId?: string; isInvite?: boolean }
| undefined; | 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) { if (detail?.isInvite) {
navigate(getInboxInvitesPath()); navigate(getInboxInvitesPath(), { replace: true });
return; return;
} }
if (detail?.roomId) navigateRoom(detail.roomId); if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
}; };
const onSubChange = () => { const onSubChange = () => {
if (isPushEnabled()) register().catch(noop); if (isPushEnabled()) register().catch(noop);
@ -350,7 +354,7 @@ export function usePushNotificationsLifecycle(): void {
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs // route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
// it doesn't have in its left-rail, hence the DM path here. // it doesn't have in its left-rail, hence the DM path here.
if (data.call_action === 'answer' && data.room_id) { if (data.call_action === 'answer' && data.room_id) {
navigate(getDirectRoomPath(data.room_id)); navigate(getDirectRoomPath(data.room_id), { replace: true });
setPendingCallAction({ setPendingCallAction({
kind: 'answer', kind: 'answer',
roomId: data.room_id, 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 // carry notif_event_id and this branch will route them to the DM
// tab incorrectly — revisit together with the group-call pipeline. // tab incorrectly — revisit together with the group-call pipeline.
if (data.room_id && data.notif_event_id) { if (data.room_id && data.notif_event_id) {
navigate(getDirectRoomPath(data.room_id)); navigate(getDirectRoomPath(data.room_id), { replace: true });
return; 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 { useCallback, useRef } from 'react';
import { NavigateOptions, useNavigate } from 'react-router-dom'; import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import { import {
@ -18,18 +18,51 @@ import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => { export const useRoomNavigate = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace(); const spaceSelectedId = useSelectedSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools'); 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( const navigateSpace = useCallback(
(roomId: string) => { (roomId: string) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
navigate(getSpacePath(roomIdOrAlias)); safeNavigate(getSpacePath(roomIdOrAlias));
}, },
[mx, navigate] [mx, safeNavigate]
); );
const navigateRoom = useCallback( const navigateRoom = useCallback(
@ -48,7 +81,7 @@ export const useRoomNavigate = () => {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
navigate( safeNavigate(
getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
opts opts
); );
@ -56,13 +89,13 @@ export const useRoomNavigate = () => {
} }
if (mDirects.has(roomId)) { if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return; return;
} }
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); safeNavigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
}, },
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] [mx, safeNavigate, spaceSelectedId, roomToParents, mDirects, developerTools]
); );
return { return {

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getHomePath, joinPathComponent } from '../../pathUtils'; import { getHomePath, joinPathComponent } from '../../pathUtils';
import { isNativePlatform } from '../../../utils/capacitor';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
import { import {
SidebarAvatar, SidebarAvatar,
@ -78,13 +79,19 @@ export function HomeTab() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleHomeClick = () => { 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'); const activePath = navToActivePath.get('home');
if (activePath && screenSize !== ScreenSize.Mobile) { if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath)); navigate(joinPathComponent(activePath), navOpts);
return; return;
} }
navigate(getHomePath()); navigate(getHomePath(), navOpts);
}; };
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {

View file

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

View file

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