diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index 5ff67ddf..6faf4ea0 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -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); diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx index 5428c85f..1ab8aecd 100644 --- a/src/app/features/call-status/IncomingCallStrip.tsx +++ b/src/app/features/call-status/IncomingCallStrip.tsx @@ -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(); diff --git a/src/app/hooks/useAndroidBackButton.ts b/src/app/hooks/useAndroidBackButton.ts index e4cdb275..4cab2ed5 100644 --- a/src/app/hooks/useAndroidBackButton.ts +++ b/src/app/hooks/useAndroidBackButton.ts @@ -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(); diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index dcf3bb22..8f94b8d0 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -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 }); } ); }) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index b2d7a91a..408d3df6 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -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 { diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index fdc84a61..749408dc 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -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 = (evt) => { diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx index 88ec6a7a..da452202 100644 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ b/src/app/pages/client/sidebar/ExploreTab.tsx @@ -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 ( diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index 822c3e11..bfb689d9 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -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(); 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 = (evt) => { diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index 497613ad..20145dce 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -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 ( diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 1e214b67..f1675a24 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -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 = (evt) => {