fix(usercenter): 刷新 /user/ 不再整页重绘(引导期乐观渲染外壳)
上一版把首帧从登录页改成居中 Loading,消除了闪登录页,但用户仍感刷两次: 静态导出首帧无法知登录态 → 先渲染裸 Loading(无顶栏/导航)→ 再出现完整外壳 → 再拉 /me 填内容。顶栏在裸 Loading 到外壳间消失再出现,观感=整页重绘两次。 改:抽出可复用 appShell(content);mount 后引导仍进行(!ready)时,若本地有 refresh token,乐观渲染完整外壳(顶栏/导航就位,仅内容区转圈),没 token 才直接登录页。 于是顶栏/导航从乐观态到已登录态始终在位,只有内容区替换 → 不再整页重绘。 SSR 安全:!mounted(SSR + 首次客户端渲染)仍渲染中性 spinner(不读 localStorage), 与服务端 HTML 一致防水合不一致;mount 后才据 hasRefresh() 决定外壳/登录页。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -45,11 +45,16 @@ export default function UserCenter() {
|
||||
|
||||
const [authed, setAuthed] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [view, setView] = useState<View>('overview');
|
||||
const [dir, setDir] = useState(0);
|
||||
const touchRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// SSR/首帧标记:服务端与客户端首次渲染都读不到 localStorage,必须渲染同一份中性
|
||||
// 首帧(避免水合不一致)。mount 后再据 hasRefresh() 乐观渲染外壳。
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const loadMe = useCallback(() => {
|
||||
api.getMe().then(setMe).catch(() => {});
|
||||
}, [api]);
|
||||
@@ -124,22 +129,6 @@ export default function UserCenter() {
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
// 引导(会话续期)未完成前一律显示加载态,不渲染登录页。关键:静态导出下 SSR 首屏
|
||||
// 读不到 localStorage(hasRefresh 服务端恒 false),若此时渲染登录页 → 刷新 /user/ 会
|
||||
// "先闪登录页再水合跳回面板"。且在 render 期调 hasRefresh() 会造成 SSR/客户端不一致。
|
||||
// 故引导期(!ready)只显示加载态(不读 localStorage),引导完再定:已登录→面板、
|
||||
// 未登录/续期失败→登录页。
|
||||
if (!ready) {
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!authed) {
|
||||
return <Login onDone={onLoginDone} />;
|
||||
}
|
||||
|
||||
const nav: [View, string, string][] = [
|
||||
['overview', 'layout-dashboard', t('navOverview')],
|
||||
['sub', 'link', t('navSub')],
|
||||
@@ -175,7 +164,10 @@ export default function UserCenter() {
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
// 应用外壳(顶栏 + 导航 + 内容区);content 由调用方决定 —— 已就绪传真实视图,
|
||||
// 引导期传加载占位。关键:外壳在「引导中(乐观)」与「已登录」两态都渲染,导航/顶栏
|
||||
// 始终在位,只有内容区替换 → 刷新时不再"整页重绘",消除"刷两次"的观感。
|
||||
const appShell = (content: React.ReactNode) => (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)', fontFamily: 'var(--font-sans)' }}>
|
||||
{/* top bar */}
|
||||
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'color-mix(in srgb, var(--bg) 85%, transparent)', backdropFilter: 'blur(12px)', borderBottom: '1px solid var(--border)' }}>
|
||||
@@ -207,9 +199,20 @@ export default function UserCenter() {
|
||||
style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '20px 16px 40px' : '30px 24px 48px' }}
|
||||
>
|
||||
<div key={view} style={{ animation: dir !== 0 ? `uc-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
|
||||
{main}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spinner = <div style={{ minHeight: '40vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>{t('loading')}</div>;
|
||||
|
||||
// ① SSR + 首次客户端渲染:读不到 localStorage,渲染中性首帧(与服务端 HTML 一致,防
|
||||
// 水合不一致)。② mount 后引导仍在进行(!ready):有 refresh token → 乐观渲染完整外壳
|
||||
// (只在内容区转圈),没 token → 直接登录页(均无闪现)。③ 引导完成:未登录→登录页、
|
||||
// 已登录→外壳 + 真实视图。外壳在 ②③ 始终在位 → 刷新不再整页重绘。
|
||||
if (!mounted) return spinner;
|
||||
if (!ready) return hasRefresh() ? appShell(spinner) : <Login onDone={onLoginDone} />;
|
||||
if (!authed) return <Login onDone={onLoginDone} />;
|
||||
return appShell(main);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user