fix(navigation): надёжная кнопка «Назад» через тип навигации, без петли (#436)

На iOS Telegram кнопка «Назад» на /subscriptions/:id казалась мёртвой:
fallback вёл на /subscriptions, который авто-редиректит обратно на деталь
(single-tariff, 1 подписка) — молчаливая петля. Причина — ненадёжный
window.history.state.idx, который мутируют собственные redirect'ы.

idx заменён на надёжный счётчик глубины по useNavigationType (PUSH +1,
POP −1, REPLACE без изменений, дедуп по location.key). Кнопка корректно
показывается/прячется, navigate(-1) берётся только при реальной истории.
Плюс fail-closed fallback: с детали уходим в '/' если список не доказанно
безопасен (multi-tariff или >1 подписки) — петля невозможна.

GitHub-issue #436.
This commit is contained in:
c0mrade
2026-05-31 15:52:09 +03:00
parent 2d5982d82b
commit a62d689fd3

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { BrowserRouter, useLocation, useNavigate } from 'react-router'; import { BrowserRouter, useLocation, useNavigate, useNavigationType } from 'react-router';
import { import {
showBackButton, showBackButton,
hideBackButton, hideBackButton,
@@ -16,7 +16,7 @@ import { WebSocketProvider } from './providers/WebSocketProvider';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { TooltipProvider } from './components/primitives/Tooltip'; import { TooltipProvider } from './components/primitives/Tooltip';
import { isInTelegramWebApp } from './hooks/useTelegramSDK'; import { isInTelegramWebApp } from './hooks/useTelegramSDK';
import { hasInAppHistory, getFallbackParentPath } from './utils/navigation'; import { getFallbackParentPath } from './utils/navigation';
import { subscriptionApi } from './api/subscription'; import { subscriptionApi } from './api/subscription';
const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const;
@@ -28,21 +28,38 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as
/** Pages reachable from bottom nav — treat as top-level (no back button). */ /** Pages reachable from bottom nav — treat as top-level (no back button). */
const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel']; const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel'];
/** Matches /subscriptions/:numericId — single-tariff users land here from /** Matches /subscriptions/:numericId. Single-tariff users land here straight
* bot deep-links, but their /subscriptions list is empty (the list view * from bot deep-links, and their /subscriptions list auto-redirects right back
* auto-redirects them straight back to this page). Pressing Back would loop * to this page (Subscriptions.tsx). So on a genuine deep-link entry (in-app
* back to detail, so on a deep-link entry (idx=0) we hide the back button * navigation depth 0) we hide the back button and let Telegram surface its
* and let Telegram surface its native Close (X) button instead. */ * native Close (X); when there IS in-app history we show it and navigate back. */
const SUBSCRIPTION_DETAIL_RE = /^\/subscriptions\/\d+\/?$/; const SUBSCRIPTION_DETAIL_RE = /^\/subscriptions\/\d+\/?$/;
function TelegramBackButton() { function TelegramBackButton() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const navType = useNavigationType();
const navigateRef = useRef(navigate); const navigateRef = useRef(navigate);
navigateRef.current = navigate; navigateRef.current = navigate;
const pathnameRef = useRef(location.pathname); const pathnameRef = useRef(location.pathname);
pathnameRef.current = location.pathname; pathnameRef.current = location.pathname;
// Reliable in-app navigation depth (the app's entry point is 0). Driven by
// React Router's navigation TYPE — NOT window.history.state.idx, which the
// app's own redirects mutate unpredictably and which is the root flake behind
// issue #436 (the back button shows/acts on the wrong state). PUSH goes
// deeper, POP unwinds, REPLACE (e.g. the Subscriptions.tsx auto-redirect) is
// flat. De-duped by location.key so StrictMode's double-effect can't miscount.
const depthRef = useRef(0);
const lastKeyRef = useRef<string | null>(null);
useEffect(() => {
if (lastKeyRef.current === location.key) return;
lastKeyRef.current = location.key;
if (navType === 'PUSH') depthRef.current += 1;
else if (navType === 'POP') depthRef.current = Math.max(0, depthRef.current - 1);
// REPLACE: depth unchanged (replaces the current entry, adds no history)
}, [location.key, navType]);
// Share the subscriptions-list query with the page-level components. // Share the subscriptions-list query with the page-level components.
// React Query dedupes by key so this does not cause an extra fetch when // React Query dedupes by key so this does not cause an extra fetch when
// Subscriptions/Subscription/Dashboard pages mount. // Subscriptions/Subscription/Dashboard pages mount.
@@ -55,10 +72,18 @@ function TelegramBackButton() {
}); });
const isMultiTariff = subData?.multi_tariff_enabled ?? false; const isMultiTariff = subData?.multi_tariff_enabled ?? false;
// Refs so the stable back handler (memoised with []) reads fresh values
// without re-subscribing — re-subscription lets a component's local handler
// overwrite ours via Telegram's singleton onBackButtonClick (issue #436).
const isMultiTariffRef = useRef(isMultiTariff);
isMultiTariffRef.current = isMultiTariff;
const subsCountRef = useRef(subData?.subscriptions?.length ?? 0);
subsCountRef.current = subData?.subscriptions?.length ?? 0;
useEffect(() => { useEffect(() => {
const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname);
const isSingleTariffDetailDeepLink = const isSingleTariffDetailDeepLink =
!isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && !hasInAppHistory(); !isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && depthRef.current === 0;
try { try {
if (isTopLevel || isSingleTariffDetailDeepLink) { if (isTopLevel || isSingleTariffDetailDeepLink) {
hideBackButton(); hideBackButton();
@@ -70,14 +95,31 @@ function TelegramBackButton() {
// Stable handler — ref prevents re-subscription on every render // Stable handler — ref prevents re-subscription on every render
const handler = useCallback(() => { const handler = useCallback(() => {
// When opened via a bot deep-link directly on a nested route, there is no // Real in-app history (depth > 0): a normal back. Otherwise we were opened
// in-app history and navigate(-1) is a no-op — the back button looks dead. // directly on this route via a deep-link — navigate(-1) is a no-op, so fall
// Fall back to the parent route so it always navigates somewhere sensible. // back to a sensible parent route instead.
if (hasInAppHistory()) { if (depthRef.current > 0) {
navigateRef.current(-1); navigateRef.current(-1);
} else { return;
navigateRef.current(getFallbackParentPath(pathnameRef.current), { replace: true });
} }
// /subscriptions/:id is special: the /subscriptions list auto-redirects
// straight back to a detail page when single-tariff with exactly one
// subscription (Subscriptions.tsx), so falling back there loops silently and
// the back button looks dead (issue #436). Land on the list ONLY when it is
// PROVABLY safe (multi-tariff, or more than one subscription — neither of
// which auto-redirects); otherwise escape to root.
//
// Fail-closed on purpose: `subsCount <= 1` is treated as not-safe, which
// also covers the stale default 0 before the shared subscriptions query
// resolves — so a fast tap on a cold cache can never route into the
// redirecting list and re-open the loop.
const pathname = pathnameRef.current;
const listIsSafe = isMultiTariffRef.current || subsCountRef.current > 1;
const fallback =
SUBSCRIPTION_DETAIL_RE.test(pathname) && !listIsSafe
? '/'
: getFallbackParentPath(pathnameRef.current);
navigateRef.current(fallback, { replace: true });
}, []); }, []);
useEffect(() => { useEffect(() => {