Files
GarminHealthLab/client/src/App.tsx
ericwyuan 570d1590eb 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>
2026-09-01 14:31:34 +08:00

399 lines
15 KiB
TypeScript

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-react';
import Framework7 from 'framework7/lite-bundle';
import Framework7React from 'framework7-react';
import routes, { tabForPath } from './routes';
import Copilot from './components/Copilot';
import { FEATURES } from './features';
import { apiClient, AUTH_EVENT } from './services/api';
import 'framework7/css/bundle';
// The icon font F7's iconIos/iconMd props reference; without it the props
// render as their raw names.
import 'framework7-icons';
import './f7theme.css';
Framework7.use(Framework7React);
/** The five thumb-reachable destinations. Secondary screens are pushed on top
* of whichever tab is active, the way an iOS app stacks them. */
const TABS = [
{ id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' },
{ id: 'health', path: '/health/', label: '健康', icon: 'heart' },
{ id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' },
{ id: 'exercise', path: '/exercise/', label: '运动', icon: 'flame' },
{ id: 'settings', path: '/settings/', label: '设置', icon: 'gear_alt' },
];
/**
* Dark mode is a deliberate, validated palette rather than an inversion. The
* choice is stamped on <html> for our own tokens, and mirrored onto F7's
* `.dark` class so its chrome follows the same switch.
*/
function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>(
() => (localStorage.getItem('ghl_theme') as any) || 'system'
);
useEffect(() => {
const root = document.documentElement;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
root.classList.toggle(
'dark',
theme === 'dark' || (theme === 'system' && media.matches)
);
};
apply();
localStorage.setItem('ghl_theme', theme);
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, [theme]);
return [theme, setTheme] as const;
}
/**
* A progress line across the top while a screen is being pushed.
*
* The transition itself is instant; what the user waits on is the new page's
* first fetch, which without this reads as a dead tap. Shown on route change
* and cleared once the incoming page has settled, with a floor on how briefly
* it can appear so a fast navigation does not produce a flash.
*/
function useNavProgress() {
const [busy, setBusy] = useState(false);
useEffect(() => {
let shownAt = 0;
let timer: number | undefined;
const show = () => {
shownAt = Date.now();
window.clearTimeout(timer);
setBusy(true);
};
const hide = () => {
const elapsed = Date.now() - shownAt;
const wait = Math.max(0, 260 - elapsed);
window.clearTimeout(timer);
timer = window.setTimeout(() => setBusy(false), wait);
};
f7ready((app) => {
app.on('routeChange', show);
app.on('pageAfterIn', hide);
app.on('pageBeforeRemove', hide);
});
return () => window.clearTimeout(timer);
}, []);
return busy;
}
/**
* The progress bar lives on document.body, not inside <F7App>.
*
* It was originally a child of the Framework7 root, and that single stray
* element broke F7's initialisation: `framework7-initializing` was never
* cleared, which forces `transition-duration: 0ms` on everything, so
* `transitionend` never fired, so the router never finished a transition —
* leaving every page with `pointer-events: none`. The visible symptom was
* back buttons that did nothing, app-wide.
*/
function NavProgress({ on }: { on: boolean }) {
return createPortal(
<div
className={`nav-progress ${on ? 'on' : ''}`}
role="status"
aria-live="polite"
aria-label={on ? '正在打开' : ''}
>
<span className="nav-progress-bar" />
</div>,
document.body
);
}
/**
* Clear Framework7's boot flag once the app is up.
*
* F7 puts `framework7-initializing` on its root to suppress animation during
* boot, and removes it on the next animation frame. When that frame does not
* arrive — a backgrounded tab, a throttled webview — the class stays, and its
* `transition-duration: 0ms !important` means transitions never emit
* `transitionend`. The router waits for that event to finish a page change, so
* it sets `allowPageChange = false` and never sets it back: every subsequent
* navigation, including every back button, silently does nothing.
*
* Clearing it ourselves is safe — by the time this runs the app is mounted,
* which is all the flag was suppressing.
*/
function useClearBootFlag() {
useEffect(() => {
const clear = () => document
.querySelector('.framework7-root')
?.classList.remove('framework7-initializing');
f7ready(clear);
// Belt and braces: f7ready itself can be scheduled off a frame.
const timer = window.setTimeout(clear, 600);
return () => window.clearTimeout(timer);
}, []);
}
/**
* Keep the router from staying blocked.
*
* Framework7 sets `router.allowPageChange = false` when a page transition
* starts and restores it when the transition's `animationend` arrives. If that
* event never comes — a throttled or backgrounded webview, where the browser
* does not run CSS animations at all — the flag stays false and every later
* navigation is silently dropped. The visible symptom is a back button that
* does nothing, and the only recovery is a full reload.
*
* This releases the flag once a page has settled, which is strictly later than
* the transition F7 is waiting on.
*/
function useRouterWatchdog() {
useEffect(() => {
let timer: number | undefined;
/* Walk the DOM rather than an app-level view registry: the shape of that
registry differs between Framework7 versions, the view elements do not. */
const stuckSince = new WeakMap<Element, number>();
// A page transition is 400ms. Anything still "transitioning" well past
// that is not transitioning — it is waiting for an event that will never
// arrive, and the stale classes are themselves what block recovery.
const STUCK_AFTER_MS = 1600;
const release = () => {
document.querySelectorAll('.view').forEach((el) => {
const view = (el as any).f7View;
if (!view?.router) return;
const midTransition = el.classList.contains('router-transition');
if (midTransition) {
const since = stuckSince.get(el);
if (!since) { stuckSince.set(el, Date.now()); return; }
if (Date.now() - since < STUCK_AFTER_MS) return;
// Only the view's transition flags. A page's own page-previous /
// page-next class is how F7 identifies the page to go back to;
// removing those makes back() rebuild the screen and stack a
// duplicate rather than popping.
el.classList.remove(
'router-transition', 'router-transition-forward',
'router-transition-backward', 'router-transition-css-forward',
'router-transition-css-backward'
);
}
stuckSince.delete(el);
if (view.router.allowPageChange === false) {
view.router.allowPageChange = true;
}
});
};
f7ready((app) => {
app.on('pageAfterIn', () => {
window.clearTimeout(timer);
timer = window.setTimeout(release, 600);
});
});
// pageAfterIn is itself part of the transition pipeline, so it can be
// missed too. A slow poll costs nothing and guarantees recovery.
const poll = window.setInterval(release, 1500);
return () => { window.clearTimeout(timer); window.clearInterval(poll); };
}, []);
}
/**
* 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.
*
* The gate belongs to the shell, not to each screen. It used to live in
* `Screen`, which every one of the five tab views renders at once — so a
* logged-out start had all five firing `navigate('/login/', {reloadAll:true})`
* at the same view, each reload remounting the others. The login form was
* being torn down and rebuilt continuously and could not be typed into.
*/
function useSession() {
const [authed, setAuthed] = useState(() => apiClient.isAuthenticated());
useEffect(() => {
const check = () => setAuthed(apiClient.isAuthenticated());
// `storage` covers other tabs; AUTH_EVENT covers this one.
window.addEventListener('storage', check);
window.addEventListener(AUTH_EVENT, check);
return () => {
window.removeEventListener('storage', check);
window.removeEventListener(AUTH_EVENT, check);
};
}, []);
return authed;
}
function App() {
useTheme();
const authed = useSession();
useClearBootFlag();
useRouterWatchdog();
useDeepLink(authed);
const navigating = useNavProgress();
return (
<>
<NavProgress on={navigating} />
<F7App
name="Garmin Health Lab"
// iOS only: the Material variants would read as a different app on the
// same screen, and the reference this is modelled on is an iOS app.
theme="ios"
darkMode="auto"
routes={routes}
touch={{ tapHold: true }}
>
{!authed ? (
/* One view, no tab bar: there is nothing to navigate to yet. */
<View main url="/login/" className="safe-areas" animate={false} />
) : (
<Views tabs className="safe-areas">
<Toolbar tabbar icons bottom>
{TABS.map((tab) => (
<Link
key={tab.id}
tabLink={`#view-${tab.id}`}
tabLinkActive={tab.id === 'today'}
iconIos={`f7:${tab.icon}`}
iconMd={`f7:${tab.icon}`}
text={tab.label}
/>
))}
</Toolbar>
{/* Each tab keeps its own navigation stack, so pushing a detail screen
inside 健康 does not disturb where 趋势 was left. */}
{TABS.map((tab) => (
<View
key={tab.id}
id={`view-${tab.id}`}
name={tab.id}
main={tab.id === 'today'}
tab
tabActive={tab.id === 'today'}
url={tab.path}
// Only the main view drives the address bar. With it enabled on
// 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
animation reports it ended. When that report never arrives the
router stays blocked and every later navigation — including
every back button — is silently dropped, and back() rebuilds
the previous screen instead of popping it.
Turning the transition off makes each page change complete
synchronously, which is correct everywhere. The motion comes
from the content instead: cards and heroes animate in, and the
route progress bar covers the load. */
animate={false}
/>
))}
</Views>
)}
</F7App>
{/* Outside <F7App> for the same reason NavProgress is: a stray child of
the Framework7 root breaks its initialisation. Only rendered with a
session — there is nothing to ask about on the login screen. */}
{authed && FEATURES.ai && <Copilot />}
</>
);
}
export default App;