Skip to content

fix(web): redirect on session expiry without reloading the page - #105

Closed
liutingqiu wants to merge 1 commit into
StepFi-app:mainfrom
liutingqiu:fix/token-refresh-client-side-redirect
Closed

liutingqiu wants to merge 1 commit into
StepFi-app:mainfrom
liutingqiu:fix/token-refresh-client-side-redirect

Conversation

@liutingqiu

Copy link
Copy Markdown

背景

对应 issue #79。src/services/api.ts 在令牌刷新失败、会话已无法恢复时,直接赋值 window.location 做跳转:

// 改动前(第 74 行与第 95 行)
window.location.href = '/'

这会触发整页文档重载:所有 React 状态、zustand store 里没被持久化的部分、以及用户当前所在的页面上下文全部丢失。issue 描述里也指出,它跳的是 /,而正确落点应是 /dashboard。

改了什么

1. src/services/api.ts —— 不再自己跳转,改为通知路由

api.ts 是普通模块:它不能调用 useNavigate(那是 hook),而赋值 window.location 正是要消除的行为。所以改为注入式回调:

let sessionExpiredHandler: (() => void) | null = null
export function setSessionExpiredHandler(handler: (() => void) | null): void

新增的 endSession() 统一处理两件事:先 clearTokens() 清空 store,再通知路由。两处失败路径(无 refreshToken 且从未登录过、以及刷新请求本身失败)都改走它。

#79 的验收第 3 条「跳转前清空所有 store 状态」由此满足 —— 清空发生在通知之前。

原有的一处刻意行为保留:若调用方带着 access token 但无 refresh token(过期会话),仍然只是清 token 并让调用方自己处理错误,不跳转,避免把用户正在看的页面拆掉。原代码的注释里写明了这个意图。

2. src/router/index.tsx —— 用 React Router 做客户端跳转

  • 新增 RedirectOnSessionExpiry() 组件:读取 store 的 sessionExpired,为真时 <Navigate to="/dashboard" replace />。这是声明式跳转,也是本仓库既有模式(Dashboard.tsx 用的就是 <Navigate>)。
  • 它被放进 page() 包装器里,因此每个路由都挂载,而不是只有首页处理过期。
  • Router() 在 useEffect 中注册一次回调:setSessionExpiredHandler(() => setSessionExpired(true))。
  • 跳转目标用 /dashboard(issue 明确要求)—— 它是角色分发器,会依据 role store 再转发到对应页面。

3. src/stores/app.store.ts —— 新增 sessionExpired 信号

新增 sessionExpired: boolean 与 setSessionExpired()。为了做到这两点:

  • partialize 只持久化 theme:sessionExpired 是一次性的跳转信号,如果被写进 localStorage,用户下次打开页面会被直接弹回 dashboard。
  • 这一点有专门测试覆盖。

4. src/test/setup.ts —— 补上 localStorage(顺带修复本仓库一直红的 9 条测试)

这不是可选的美化,是跑通测试的必要条件,理由值得记录:

  • 当前环境下 window.localStorage 是 undefined;Node 24+ 会用只有 getter、没有 setter 的全局访问器把它遮住,因此 vi.stubGlobal('localStorage', …) 静默失效。
  • 更关键的是:zustand 的 persist 在模块加载时就读一次 window.localStorage(createJSONStorage(() => window.localStorage),见 zustand/middleware 第 332 行),之后永久持有那个引用。所以任何写在 beforeEach 里的补丁都太晚了 —— 必须在 setup 文件里、任何测试模块被 import 之前生效。

在此之前,npm test 在本仓库是红的:3 个文件 / 9 条测试因 localStorage 缺失而失败(user.store.test.ts、wallet.store.test.ts、api.test.ts)。本改动把它们一并修好。

测试

  • src/services/__tests__/api.test.ts(扩充):
    • 无会话可恢复时 → 调用注入的 handler,且 store 与 localStorage 均被清空;
    • 刷新请求失败时 → 清空并通知(撤销了原来"失败即跳转"的写法);
    • 有 access token 但无 refresh token 的过期会话 → 不跳转(保护既有行为);
    • 未注册 handler 时 → 不抛错(服务层不硬依赖已挂载的路由)。
  • src/stores/__tests__/app.store.test.ts(新增):信号默认关闭、可升可降、且不被持久化。

验证方式

npm ci
npm run build      # 含 tsc -b 全量类型检查
npm test
npm run lint
npm run test:coverage

本机(Windows,Node v26.4.0,仓库未固定 node 版本)实测:

门禁 结果
npm run build 通过(tsc -b 无错误,vite build 成功)
npm test 12 文件 / 60 测试全部通过(改动前为 3 文件 / 9 测试失败)
npm run lint 通过(exit 0)
npm run test:coverage 通过;All files 69.49% statements / 59.04% branches / 71.3% functions / 70.28% lines,均高于 vite.config.ts 里的 60/50/65/60 门槛

关于 window.location

改动后 src/services/api.ts 中不再存在对 window.location 的赋值(仅保留注释里说明为何不能这么做)。跳转 100% 由 React Router 完成,不会重载文档。

Closes #79

The api interceptor assigned window.location.href when a token refresh failed, which reloads the whole document and discards all React and store state. It also sent users to / instead of /dashboard.

The service now clears the store and calls an injected handler, and the router registers that handler to navigate declaratively with <Navigate>. Also polyfill localStorage in the test setup so the store-backed suites can run.

@EmeditWeb EmeditWeb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Automated Audit: partial

@liutingqiu Good start — please look into the gaps identified below.

The diff genuinely addresses the root cause: both window.location.href = '/' assignments in src/services/api.ts are gone, replaced by an injected setSessionExpiredHandler callback that first calls clearTokens() and then raises a sessionExpired flag which RedirectOnSessionExpiry turns into a declarative <Navigate to="/dashboard" replace />, and the interceptor still rejects the original error (asserted in the new tests). Two author-fixable defects remain: the sessionExpired signal is never reset after the redirect, so once raised every route's page() wrapper keeps forcing /dashboard (pinning the user there even after re-login, and risking a ping-pong with Dashboard's own <Navigate to="/"> when no role is selected); and the new partialize: (state) => ({ theme }) silently drops persistence of onboardingComplete, an out-of-scope behavioral regression that only needed sessionExpired excluded. No test exercises the router side (the actual /dashboard navigation and handler registration/cleanup), which is the issue's second acceptance criterion. The test-setup localStorage polyfill strengthens rather than weakens verification, and no CI config was touched; note that the PR's claimed results (60 tests passing, lint/build green, coverage figures) could not be independently reproduced (no CI configured and the sandbox lacked npm), so those claims stand unverified — this is tooling context, not held against the author.

Gaps identified:

  • sessionExpired is never set back to false after the redirect — once raised, every route rendered through page() returns <Navigate to="/dashboard" replace />, trapping the user on /dashboard even after a successful re-login and risking a redirect loop with Dashboard's own <Navigate to="/"> fallback; clear the flag once the navigation has been performed.
  • partialize: (state) => ({ theme: state.theme }) stops persisting onboardingComplete (previously persisted by default) — an unrelated behavioral regression; exclude only the transient sessionExpired key and keep existing persisted fields.
  • No test for the router half of the fix: RedirectOnSessionExpiry actually navigating to /dashboard (acceptance criterion 2) and Router() registering/unregistering the handler via setSessionExpiredHandler.
  • PR body does not satisfy the structure contract as checked (missing explicit Summary and Changes sections in the required form); add the required sections so the structure gate passes.

CI checks: none configured
Merge conflicts: ✅ none

Audited by stepfi-audit-bot 🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: use router navigation on token refresh failure

2 participants