Skip to content

chore: 修复 react-hooks lint 错误#16

Open
xPeiPeix wants to merge 1 commit into
masterfrom
chore/fix-react-hooks-lint
Open

chore: 修复 react-hooks lint 错误#16
xPeiPeix wants to merge 1 commit into
masterfrom
chore/fix-react-hooks-lint

Conversation

@xPeiPeix

@xPeiPeix xPeiPeix commented Apr 30, 2026

Copy link
Copy Markdown
Owner

Summary

清掉 master 已有的 5 处 react-hooks lint 错误,让 npm run lint 重回 0 errors(仅剩 .venv vendor 的 1 条 warning,与项目源码无关)。

Changes

  • components/vocab-card.tsxDate.now() 由 render 体移入 useState(() => Date.now()) 在 mount 时锁定,render 保持 pure
  • components/voice-picker.tsxsetSelected(getSelectedVoiceName()) 从 useEffect 移入 useState initializer,避免 cascading render
  • components/youtube-player.tsxreloadPlayerRef.current = reloadPlayer 包进 useEffect,render 不再写 ref
  • components/theme-toggle.tsxuseState/useEffect 的 mounted 模式改用 useSyncExternalStore,hydration-safe 且避免 setState in effect
  • lib/azure-tts.ts — 删除未使用的 catch (err) binding,改为 optional catch binding

Key Design Decisions

theme-toggle 改用 useSyncExternalStore
理由:next-themes 的"mounted hack"是 SSR/client 一致性的经典 pattern,但 React 19 的 react-hooks/set-state-in-effect 规则视其为反模式。useSyncExternalStore 通过 server snapshot/client snapshot 双源,是 React 18+ 官方推荐的 hydration-safe 替代方案,比 eslint-disable 注释更干净。

How to Review

  1. components/vocab-card.tsx:51-56decorativeLevel 计算结果不变,仅 Date.now() 改为 mount 时一次性 capture("创建至今天数 / 3" 装饰等级,无需 render 时实时更新)
  2. components/voice-picker.tsx:22-28getSelectedVoiceName 内部已有 typeof window === "undefined" 守卫,useState initializer 直接调用安全
  3. components/youtube-player.tsx:104-107 — useEffect 不带依赖数组(每次 render 后跑),与原"render 时同步赋值"行为等价
  4. components/theme-toggle.tsx:7-22subscribe/getClientSnapshot/getServerSnapshot 三个函数提到模块作用域,避免每次 render 重建(useSyncExternalStore 用引用相等判断)

Testing

  • npm run lint 通过:源码 0 errors(剩唯一 warning 来自 .venv/ yt-dlp vendor 文件)
  • npm run build 通过:17 个静态页全部生成,路由清单完整

Reproduce Locally

git fetch origin
git checkout chore/fix-react-hooks-lint
npm install
npm run lint
npm run build

Summary by Sourcery

Resolve existing React hooks lint violations and align components with recommended React patterns.

Bug Fixes:

  • Fix multiple React hooks lint errors across UI components to restore a clean lint run.

Enhancements:

  • Make theme toggle mounting logic hydration-safe by switching to useSyncExternalStore.
  • Stabilize time-based calculations in vocab cards by capturing Date.now once per mount.
  • Initialize selected voice state from the current browser preference at mount time to avoid extra renders.
  • Update YouTube player reload ref assignment to run inside an effect for a pure render cycle.
  • Simplify Azure TTS error handling by using an optional catch binding.

@sourcery-ai

sourcery-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR fixes existing react-hooks lint violations by making state initialization and ref updates render-pure, refactoring a mounted flag to useSyncExternalStore for hydration-safe behavior, and cleaning up an unused catch binding in Azure TTS utilities.

Sequence diagram for ThemeToggle hydration-safe mounted flag using useSyncExternalStore

sequenceDiagram
  participant ReactServer
  participant Browser
  participant ThemeToggle

  Note over ReactServer,ThemeToggle: Server-side render
  ReactServer->>ThemeToggle: render()
  ThemeToggle->>ThemeToggle: useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
  ThemeToggle-->>ReactServer: mounted = getServerSnapshot() = false
  ReactServer-->>Browser: HTML with mounted = false

  Note over Browser,ThemeToggle: Client hydration
  Browser->>ThemeToggle: hydrate existing HTML
  ThemeToggle->>ThemeToggle: useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
  ThemeToggle-->>Browser: mounted = getClientSnapshot() = true
  Browser->>ThemeToggle: render with mounted = true
  ThemeToggle->>ThemeToggle: isDark = mounted && resolvedTheme === dark
  ThemeToggle-->>Browser: final UI with correct theme state
Loading

Flow diagram for VoicePicker initial selected voice without extra render

flowchart TD
  A[Component mount] --> B[Initialize voices state to empty array]
  B --> C[Initialize selected state via getSelectedVoiceName]
  C --> D[Initialize open state to false]
  D --> E[Initialize previewing state to false]
  E --> F[Run effect to load voices]
  F --> G[If window and speechSynthesis available]
  G -->|yes| H[Call load to populate voices]
  H --> I[Add voiceschanged listener calling load]
  G -->|no| J[Skip loading voices]
  I --> K[On unmount remove voiceschanged listener]
  J --> K[Component lifecycle complete]
Loading

File-Level Changes

Change Details Files
Make decorative level computation in vocab card render-pure by capturing Date.now() once at mount time.
  • Introduce a useState hook to capture the current timestamp once on component mount.
  • Replace direct Date.now() usage in decorative level calculation with the captured timestamp state value.
components/vocab-card.tsx
Initialize selected voice from getSelectedVoiceName in state initializer instead of useEffect to avoid cascading renders.
  • Change the selected voice state hook to use a lazy initializer that calls getSelectedVoiceName.
  • Remove the effect that previously called setSelected(getSelectedVoiceName()) on mount.
  • Keep the speechSynthesis voiceschanged event wiring and cleanup logic unchanged.
components/voice-picker.tsx
Move reloadPlayerRef.current mutation into an effect to keep render side-effect free while preserving behavior.
  • Create a useEffect that assigns reloadPlayer to reloadPlayerRef.current after each render.
  • Remove the direct assignment to reloadPlayerRef.current from the render body.
  • Leave the ref initialization useRef(reloadPlayer) intact.
components/youtube-player.tsx
Replace mounted useState/useEffect pattern in theme toggle with useSyncExternalStore for hydration-safe mounting state.
  • Import useSyncExternalStore from react instead of useEffect and useState.
  • Define subscribe, getClientSnapshot, and getServerSnapshot helpers at module scope for stable identities.
  • Use useSyncExternalStore in ThemeToggle to derive a mounted flag that is false on the server and true on the client, and remove the previous useEffect that set mounted to true.
components/theme-toggle.tsx
Use optional catch binding in Azure TTS error handling to remove an unused error variable.
  • Update the try/catch block to omit the err parameter in the catch clause, avoiding an unused variable while preserving error handling behavior.
lib/azure-tts.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • In ThemeToggle, the useSyncExternalStore implementation isn’t actually wired to any external store and always returns true on the client, which may still cause a server/client snapshot mismatch and makes the extra subscription indirection unnecessary—consider either wiring it to the actual next-themes state or using a documented hydration-safe pattern that ensures server and client snapshots initially match.
  • In VocabCard, now is captured once via useState(() => Date.now()), so if the same component instance can receive a different entry prop over its lifetime, decorativeLevel will be computed against a stale timestamp—if entries can change in place, consider keying the component by entry.id or recalculating now when entry.createdAt changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `ThemeToggle`, the `useSyncExternalStore` implementation isn’t actually wired to any external store and always returns `true` on the client, which may still cause a server/client snapshot mismatch and makes the extra subscription indirection unnecessary—consider either wiring it to the actual `next-themes` state or using a documented hydration-safe pattern that ensures server and client snapshots initially match.
- In `VocabCard`, `now` is captured once via `useState(() => Date.now())`, so if the same component instance can receive a different `entry` prop over its lifetime, `decorativeLevel` will be computed against a stale timestamp—if entries can change in place, consider keying the component by `entry.id` or recalculating `now` when `entry.createdAt` changes.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da7cdf4835

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +23 to +25
const [selected, setSelected] = useState<string | null>(() =>
getSelectedVoiceName()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Rehydrate selected voice from client storage

Initializing selected from getSelectedVoiceName() in useState breaks persisted voice selection on SSR/hydrated loads: this initializer runs during server render (where window is unavailable, so it returns null), and hydration reuses that state rather than re-reading localStorage on mount. Since this commit also removed the mount-time setSelected(getSelectedVoiceName()), users who refresh or land directly on the page lose their saved voice until they manually reselect one.

Useful? React with 👍 / 👎.

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.

1 participant