refactor(router): centralize app route transitions

This commit is contained in:
2026-07-03 14:15:52 +08:00
parent 91dde42f92
commit 873a0a8bce
10 changed files with 188 additions and 133 deletions
+5 -5
View File
@@ -5,11 +5,11 @@
*
*/
import { useEffect, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { AuthBackground, AuthPanel } from "./components";
import styles from "./components/auth-screen.module.css";
@@ -20,7 +20,7 @@ const log = new Logger("AppAuthAuthScreen");
export function AuthScreen() {
const state = useAuthState();
const authDispatch = useAuthDispatch();
const router = useRouter();
const navigator = useAppNavigator();
const redirectTo = useSyncExternalStore(
subscribeLocationSnapshot,
readRedirectFromLocation,
@@ -40,17 +40,17 @@ export function AuthScreen() {
});
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
log.debug("[auth-screen] useEffect → router.replace [via pendingRedirect flag]", {
log.debug("[auth-screen] useEffect → navigator.replace [via pendingRedirect flag]", {
redirectTo: safeRedirectTo,
});
authDispatch({ type: "AuthClearPendingRedirect" });
router.replace(safeRedirectTo);
navigator.replace(safeRedirectTo);
}
}, [
state.loginStatus,
state.pendingRedirect,
authDispatch,
router,
navigator,
safeRedirectTo,
]);
+3 -4
View File
@@ -2,9 +2,8 @@
/**
* 认证面板:顶层 switchFacebook / Email+ 悬浮返回按钮
*/
import { useRouter } from "next/navigation";
import { BackButton } from "@/app/_components";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { AuthEmailPanel } from "./auth-email-panel";
@@ -14,7 +13,7 @@ import styles from "./auth-panel.module.css";
export function AuthPanel() {
const state = useAuthState();
const dispatch = useAuthDispatch();
const router = useRouter();
const navigator = useAppNavigator();
const switchToFacebook = () =>
dispatch({ type: "AuthPanelModeChanged", mode: "facebook" });
@@ -25,7 +24,7 @@ export function AuthPanel() {
if (state.authPanelMode === "email") {
switchToFacebook();
} else {
router.back();
navigator.back();
}
};
+3 -4
View File
@@ -9,10 +9,9 @@
* - 错误占位符(broken image icon
*/
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
isBrowserLocalChatImageSource,
normalizeChatImageSource,
@@ -33,7 +32,7 @@ export function ImageBubble({
imageUrl,
imagePaywalled = false,
}: ImageBubbleProps) {
const router = useRouter();
const navigator = useAppNavigator();
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
@@ -58,7 +57,7 @@ export function ImageBubble({
const openImage = () => {
if (!messageId) return;
requestChatScrollSnapshotSave(messageId);
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
navigator.openChatImage(messageId);
};
return (
@@ -6,14 +6,13 @@
* 设计:
* - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
* - deviceId / fbid / avatarUrl 统一交给 `lib/chat` 落地。
* - 写入完成后 `router.replace('/chat')`URL 不留深链痕迹。
* - 写入完成后通过统一导航回到 `/chat`URL 不留深链痕迹。
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { persistExternalBrowserChatDeepLink } from "@/lib/chat/chat_external_browser";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { Logger } from "@/utils";
const log = new Logger("AppChatDeviceidDeviceIdDeepLinkPersist");
@@ -29,7 +28,7 @@ export default function DeepLinkPersist({
fbid,
avatarUrl,
}: DeepLinkPersistProps) {
const router = useRouter();
const navigator = useAppNavigator();
const [done, setDone] = useState(false);
useEffect(() => {
@@ -45,10 +44,10 @@ export default function DeepLinkPersist({
log.warn("[DeepLinkPersist] failed to persist", err);
} finally {
setDone(true);
router.replace(ROUTES.chat);
navigator.openChat({ replace: true });
}
})();
}, [avatarUrl, deviceId, fbid, router]);
}, [avatarUrl, deviceId, fbid, navigator]);
return (
<div
+1 -1
View File
@@ -3,7 +3,7 @@
*
* 对齐 Flutter `lib/router/app_router.dart:60-78` 的 `GoRoute(path: '/chat/deviceid/:deviceId')`
* 把 `deviceId` 路径参数与 `fbid` / `avatarUrl` 查询参数透传给 Client 组件
* `<DeepLinkPersist>`,由它把数据写入 localStorage 后 `router.replace('/chat')`。
* `<DeepLinkPersist>`,由它把数据写入 localStorage 后通过统一导航回到 `/chat`。
*
* Next.js 15+ 约定:`params` / `searchParams` 是 Promise,必须 await。
* Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` /
@@ -1,9 +1,8 @@
"use client";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import {
@@ -21,7 +20,7 @@ export interface ChatImageViewerScreenProps {
export function ChatImageViewerScreen({
messageId,
}: ChatImageViewerScreenProps) {
const router = useRouter();
const navigator = useAppNavigator();
const chatState = useChatState();
const chatDispatch = useChatDispatch();
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
@@ -40,7 +39,7 @@ export function ChatImageViewerScreen({
);
const handleClose = () => {
router.push(ROUTES.chat, { scroll: false });
navigator.openChat({ scroll: false });
};
const handleUnlockImagePaywall = () => {
+8 -9
View File
@@ -1,12 +1,11 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { hasBusinessLoginToken } from "@/lib/auth/auth_session";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { ROUTES } from "@/router/routes";
import { pwaUtil, Logger } from "@/utils";
import {
@@ -25,13 +24,13 @@ if (typeof window !== "undefined") {
}
export function SplashScreen() {
const router = useRouter();
const navigator = useAppNavigator();
const state = useAuthState();
const authDispatch = useAuthDispatch();
const handleSkip = () => {
authDispatch({ type: "AuthGuestLoginSubmitted" });
router.replace(ROUTES.chat);
navigator.openChat({ replace: true });
};
useEffect(() => {
@@ -49,13 +48,13 @@ export function SplashScreen() {
let cancelled = false;
void (async () => {
if (!cancelled && (await hasBusinessLoginToken())) {
router.replace(ROUTES.chat);
navigator.openChat({ replace: true });
}
})();
return () => {
cancelled = true;
};
}, [router]);
}, [navigator]);
// ─────────────────────────────────────────────────────────────
// useEffect ② 按钮刚派了事件(pendingRedirect=true +
@@ -76,12 +75,12 @@ export function SplashScreen() {
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
log.debug(
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
"[splash] useEffect ② → navigator.openChat [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
router.replace(ROUTES.chat);
navigator.openChat({ replace: true });
}
}, [state.loginStatus, state.pendingRedirect, authDispatch, router]);
}, [state.loginStatus, state.pendingRedirect, authDispatch, navigator]);
return (
<MobileShell background="var(--color-sidebar-background)">
+5 -5
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import {
@@ -9,11 +8,12 @@ import {
getPendingPaymentOrder,
} from "@/lib/payment/pending_payment_order";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
type ReturnStatus = "loading" | "missing";
export default function SubscriptionReturnPage() {
const router = useRouter();
const navigator = useAppNavigator();
const [status, setStatus] = useState<ReturnStatus>("loading");
useEffect(() => {
@@ -24,7 +24,7 @@ export default function SubscriptionReturnPage() {
if (cancelled) return;
if (result.success && result.data !== null) {
router.replace(buildPendingPaymentSubscriptionUrl(result.data));
navigator.replace(buildPendingPaymentSubscriptionUrl(result.data));
return;
}
@@ -35,7 +35,7 @@ export default function SubscriptionReturnPage() {
return () => {
cancelled = true;
};
}, [router]);
}, [navigator]);
return (
<MobileShell>
@@ -62,7 +62,7 @@ export default function SubscriptionReturnPage() {
{status === "missing" ? (
<button
type="button"
onClick={() => router.replace(ROUTES.subscription)}
onClick={() => navigator.replace(ROUTES.subscription)}
style={{
marginTop: "0.5rem",
padding: "0.75rem 1.5rem",
@@ -1,16 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
consumeSubscriptionExitUrl,
peekSubscriptionExplicitExitUrl,
} from "@/lib/navigation/subscription_exit";
import {
clearPendingPaymentOrder,
getPendingPaymentOrderForType,
} from "@/lib/payment/pending_payment_order";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentState,
@@ -33,7 +29,7 @@ export function useSubscriptionPaymentFlow({
returnTo,
initialPayChannel,
}: UseSubscriptionPaymentFlowInput) {
const router = useRouter();
const navigator = useAppNavigator();
const userDispatch = useUserDispatch();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
@@ -148,22 +144,13 @@ export function useSubscriptionPaymentFlow({
}, [payment.currentOrderId, payment.isPaid, payment.status]);
const handleBackClick = () => {
void (async () => {
router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
navigator.exitSubscription(returnTo);
};
const handlePaymentSuccessClose = () => {
void (async () => {
setShowPaymentSuccessDialog(false);
paymentDispatch({ type: "PaymentReset" });
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
if (pendingExitUrl) {
router.replace(pendingExitUrl);
return;
}
router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
navigator.exitSubscriptionAfterSuccess(returnTo);
};
return {
+92 -19
View File
@@ -1,8 +1,14 @@
"use client";
import { useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { NavigationStorage } from "@/data/storage/navigation";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import {
consumeSubscriptionExitUrl,
peekSubscriptionExplicitExitUrl,
} from "@/lib/navigation/subscription_exit";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserState } from "@/stores/user/user-context";
@@ -25,8 +31,13 @@ import type {
export interface AppNavigator {
push: (href: string, options?: { scroll?: boolean }) => void;
replace: (href: string, options?: { scroll?: boolean }) => void;
back: () => void;
openAuth: (redirectTo?: string) => void;
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
openChatImage: (messageId: string) => void;
openSubscription: (input: OpenSubscriptionInput) => void;
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
startMessageUnlock: (input: StartMessageUnlockInput) => void;
openSubscriptionForPendingUnlock: (
input: OpenSubscriptionForPendingUnlockInput,
@@ -41,28 +52,46 @@ export function useAppNavigator(): AppNavigator {
const userState = useUserState();
const isAuthenticatedUser = resolveIsAuthenticatedUser(authState.loginStatus);
function push(href: string, options?: { scroll?: boolean }): void {
const push = useCallback((href: string, options?: { scroll?: boolean }): void => {
router.push(href, options);
}
}, [router]);
function replace(href: string, options?: { scroll?: boolean }): void {
const replace = useCallback((href: string, options?: { scroll?: boolean }): void => {
router.replace(href, options);
}
}, [router]);
function openAuth(redirectTo: string = ROUTES.chat): void {
const back = useCallback((): void => {
router.back();
}, [router]);
const openAuth = useCallback((redirectTo: string = ROUTES.chat): void => {
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
}
}, [router]);
function getDefaultPayChannel() {
const openChat = useCallback((options: { replace?: boolean; scroll?: boolean } = {}): void => {
const navOptions = { scroll: options.scroll ?? false };
if (options.replace) {
router.replace(ROUTES.chat, navOptions);
return;
}
router.push(ROUTES.chat, navOptions);
}, [router]);
const openChatImage = useCallback((messageId: string): void => {
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
}, [router]);
const getDefaultPayChannel = useCallback(() => {
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
}
}, [userState.currentUser?.countryCode]);
function openSubscription({
const openSubscription = useCallback(
({
type,
payChannel = getDefaultPayChannel(),
returnTo = null,
replace: shouldReplace = false,
}: OpenSubscriptionInput): void {
}: OpenSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
@@ -78,15 +107,18 @@ export function useAppNavigator(): AppNavigator {
return;
}
router.push(nextUrl);
}
},
[authState.loginStatus, getDefaultPayChannel, router],
);
function startMessageUnlock({
const startMessageUnlock = useCallback(
({
messageId,
kind,
returnUrl,
stage = "auth",
onAuthenticated,
}: StartMessageUnlockInput): void {
}: StartMessageUnlockInput): void => {
if (isAuthenticatedUser) {
onAuthenticated();
return;
@@ -101,15 +133,18 @@ export function useAppNavigator(): AppNavigator {
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
}
},
[isAuthenticatedUser, router],
);
function openSubscriptionForPendingUnlock({
const openSubscriptionForPendingUnlock = useCallback(
({
messageId,
kind,
returnUrl,
type,
payChannel = getDefaultPayChannel(),
}: OpenSubscriptionForPendingUnlockInput): void {
}: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
messageId,
@@ -123,18 +158,56 @@ export function useAppNavigator(): AppNavigator {
returnTo: "chat",
});
})();
}
},
[getDefaultPayChannel, openSubscription],
);
return {
const exitSubscription = useCallback((returnTo: SubscriptionReturnTo): void => {
void (async () => {
router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
}, [router]);
const exitSubscriptionAfterSuccess = useCallback((returnTo: SubscriptionReturnTo): void => {
void (async () => {
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
if (pendingExitUrl) {
router.replace(pendingExitUrl);
return;
}
router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
}, [router]);
return useMemo(() => ({
push,
replace,
back,
openAuth,
openChat,
openChatImage,
openSubscription,
exitSubscription,
exitSubscriptionAfterSuccess,
startMessageUnlock,
openSubscriptionForPendingUnlock,
getDefaultPayChannel,
isAuthenticatedUser,
};
}), [
back,
exitSubscription,
exitSubscriptionAfterSuccess,
getDefaultPayChannel,
isAuthenticatedUser,
openAuth,
openChat,
openChatImage,
openSubscription,
openSubscriptionForPendingUnlock,
push,
replace,
startMessageUnlock,
]);
}
export type { Route };