From 570d1590eb3408caecfc26b79636d16af42508a4 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Tue, 1 Sep 2026 14:31:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(router):=20=E6=B7=B1=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E5=85=A8=E8=A2=AB=E5=A1=9E=E8=BF=9B=E3=80=8C=E4=BB=8A=E6=97=A5?= =?UTF-8?q?=E3=80=8D=EF=BC=8C=E7=82=B9=E5=BA=95=E9=83=A8=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=E6=98=BE=E7=A4=BA=E7=9A=84=E6=98=AF=E5=88=AB=E4=BA=BA?= =?UTF-8?q?=E7=9A=84=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开 …/sleep/ 之后,底部点「今日」出来的是睡眠,点「健康」出来的健康从来 没去过地址栏说的地方。 原因:browserHistory 只开在主视图(今日)上,而 Framework7 在启动时也会用 它去消费地址栏——它只认识这一个视图。于是不管哪个标签页真正拥有那个路径, 睡眠都被压进了**今日**的栈,还顺手伪造了一份两条记录的 history。 - View 加 browserHistoryOnLoad={false}。注意不是 browserHistoryInitialMatch ——那个只决定两条记录里渲染哪一条,伪造的栈照样留着(读 router-class.js 的 getInitialUrl 才看明白)。之后的导航追踪不受影响。 - routes.ts 加 tabForPath():冷启动的 URL 归哪个标签页。只有深链接查这张表, 应用内点击照旧——从今日卡片点进睡眠,仍然留在今日。 - App.tsx 的 useDeepLink 负责派发初始地址,并把目标页压在该标签页自己的根 之上,这样返回箭头回到的是健康,而不是空栈。 派发要延后一帧再做、并在 unmount 时取消:StrictMode 会把 挂两次, 第一次挂载的 F7 视图会被销毁重建,在它上面导航的结果全丢——症状是一个睡眠 页孤零零留在 DOM 里,router 却坚称自己在健康。另外比较路径要先归一化斜杠, 每个页面都注册了带斜杠和不带斜杠两种写法,直接比会把趋势压在趋势上面。 Co-Authored-By: Claude Opus 5 --- client/src/App.tsx | 72 +++++++++++++++++++++++++++++++++++++++++++- client/src/routes.ts | 29 ++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 5d60c28..8414b61 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,7 +4,7 @@ import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-re import Framework7 from 'framework7/lite-bundle'; import Framework7React from 'framework7-react'; -import routes from './routes'; +import routes, { tabForPath } from './routes'; import Copilot from './components/Copilot'; import { FEATURES } from './features'; import { apiClient, AUTH_EVENT } from './services/api'; @@ -220,6 +220,67 @@ function useRouterWatchdog() { }, []); } +/** + * Open a cold URL in the tab that owns it. + * + * The main view has `browserHistory`, which is what gives 今日 shareable + * URLs — but Framework7 also applies it on boot, and it only knows about + * that one view. So loading `…/sleep/` pushed 睡眠 onto **今日's** stack: + * the tab bar highlighted 今日, tapping 今日 showed 睡眠, and tapping 健康 + * showed a 健康 that had never been where the address bar said it was. + * + * `browserHistoryInitialMatch={false}` on that view stops F7 from doing it, + * and the initial address is dispatched here instead — to the tab that owns + * the path, with that tab activated. In-app navigation is untouched: a detail + * screen still opens inside whichever tab you were already in. + */ +/* Module scope, not a ref: this must happen once per page load, and a ref is + reset by the remount it needs to survive. */ +let deepLinkDispatched = false; + +function useDeepLink(authed: boolean) { + useEffect(() => { + if (!authed || deepLinkDispatched) return; + + const path = window.location.pathname; + const tab = tabForPath(path); + if (!tab) return; + + /* Deferred, with the timer cleared on unmount, because StrictMode mounts + twice: the first mount's Framework7 view is destroyed and rebuilt + from its `url` prop, so anything navigated on it is discarded — leaving + a 睡眠 page orphaned in the DOM while the router insisted it was on + 健康. Cancelling on unmount means only the surviving mount dispatches. */ + const timer = window.setTimeout(() => { + f7ready((app) => { + const view = app.views.get(`#view-${tab}`); + if (!view) return; + deepLinkDispatched = true; + app.tab.show(`#view-${tab}`); + // The tab's own root is already loaded, so this pushes the deep-linked + // screen on top of it — which is what makes the back chevron return to + // 健康 rather than to an empty stack. Skipped when the path *is* that + // root, or the tab would stack a duplicate of what it already shows. + // + // Compared with trailing slashes normalised, because every screen is + // registered under both spellings (see routes.ts): `…/trends` and the + // view's own `/trends/` are the same screen, and comparing them raw + // stacked 趋势 on top of 趋势. + const slashed = (url: string) => (url.endsWith('/') ? url : `${url}/`); + const current = view.router.history[view.router.history.length - 1] || ''; + if (slashed(current) !== slashed(path)) { + view.router.navigate(slashed(path), { animate: false }); + } + }); + }, 0); + + return () => window.clearTimeout(timer); + // Runs once per page load: re-running it on a later render would yank the + // user back to the address they arrived at. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authed]); +} + /** * Whether there is a session, kept live for this tab. * @@ -251,6 +312,7 @@ function App() { const authed = useSession(); useClearBootFlag(); useRouterWatchdog(); + useDeepLink(authed); const navigating = useNavProgress(); return ( @@ -298,6 +360,14 @@ function App() { // every tab each one reads the browser URL instead of its own // `url`, so all five loaded the root page. browserHistory={tab.id === 'today'} + /* Stops F7 from consuming the address bar on boot: it would load + that path into THIS view whichever tab actually owns it, and + fabricate a two-entry history to match. `…InitialMatch={false}` + is not enough — it only changes which of the two entries gets + rendered, leaving the bogus stack in place. Tracking for later + navigation is unaffected; the initial address is dispatched by + useDeepLink instead. */ + browserHistoryOnLoad={false} browserHistorySeparator="" /* Framework7's page transition is animation-driven: the router sets allowPageChange = false on push and restores it when the diff --git a/client/src/routes.ts b/client/src/routes.ts index 27e4fe3..8786497 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -67,4 +67,33 @@ const routes: Router.RouteParameters[] = [ { path: '(.*)', component: NotFoundPage }, ]; +/** + * Which tab a **cold URL** belongs to. + * + * Only deep links consult this. Navigating in the app keeps pushing detail + * screens onto whichever tab you are already in — 睡眠 opened from a 今日 card + * stays in 今日 — and that stays true; this table answers the different + * question of where a screen should appear when it is the first thing loaded. + * + * Order matters: the first pattern that matches wins, so `/body-age/` has to + * be tested before `/body/`. + */ +const TAB_OWNERS: Array<[RegExp, string]> = [ + [/^\/(health|sleep|body-age|body)\/?$/, 'health'], + [/^\/(trends|daily)\/?$/, 'trends'], + [/^\/metric\//, 'trends'], + [/^\/(exercise|race|challenges)\/?$/, 'exercise'], + [/^\/activity\//, 'exercise'], + [/^\/(settings|sync|devices|rating-basis)\/?$/, 'settings'], +]; + +/** The tab a cold URL should open in, or null for the root and unknown paths. */ +export function tabForPath(path: string): string | null { + if (!path || path === '/' || path === '') return null; + for (const [pattern, tab] of TAB_OWNERS) { + if (pattern.test(path)) return tab; + } + return null; +} + export default routes;