mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user