fix(router): 深链接全被塞进「今日」,点底部标签页显示的是别人的内容

打开 …/sleep/ 之后,底部点「今日」出来的是睡眠,点「健康」出来的健康从来
没去过地址栏说的地方。

原因:browserHistory 只开在主视图(今日)上,而 Framework7 在启动时也会用
它去消费地址栏——它只认识这一个视图。于是不管哪个标签页真正拥有那个路径,
睡眠都被压进了**今日**的栈,还顺手伪造了一份两条记录的 history。

- View 加 browserHistoryOnLoad={false}。注意不是 browserHistoryInitialMatch
  ——那个只决定两条记录里渲染哪一条,伪造的栈照样留着(读 router-class.js
  的 getInitialUrl 才看明白)。之后的导航追踪不受影响。
- routes.ts 加 tabForPath():冷启动的 URL 归哪个标签页。只有深链接查这张表,
  应用内点击照旧——从今日卡片点进睡眠,仍然留在今日。
- App.tsx 的 useDeepLink 负责派发初始地址,并把目标页压在该标签页自己的根
  之上,这样返回箭头回到的是健康,而不是空栈。

派发要延后一帧再做、并在 unmount 时取消:StrictMode 会把 <View> 挂两次,
第一次挂载的 F7 视图会被销毁重建,在它上面导航的结果全丢——症状是一个睡眠
页孤零零留在 DOM 里,router 却坚称自己在健康。另外比较路径要先归一化斜杠,
每个页面都注册了带斜杠和不带斜杠两种写法,直接比会把趋势压在趋势上面。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 14:31:34 +08:00
parent f6687d2b5d
commit 570d1590eb
2 changed files with 100 additions and 1 deletions

View File

@@ -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
<View> 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