Skip to content

chore(lint): adopt eslint-plugin-react-hooks v7 React-Compiler rules (#1063) - #1068

Merged
xiaolai merged 6 commits into
mainfrom
chore/react-hooks-7-adoption-1063
Jun 27, 2026
Merged

xiaolai merged 6 commits into
mainfrom
chore/react-hooks-7-adoption-1063

Conversation

@xiaolai

@xiaolai xiaolai commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Completes the deferred react-hooks-7 adoption from #1062. The four React-Compiler rules folded into recommended (which flagged 68 pre-existing sites) are now fully on at error, adopted one rule at a time (one commit per rule).

Approach

Each site was classified, not blanket-disabled:

  • Genuine "adjust state during render" smells → refactored to render-phase setState (React's recommended alternative — no extra render, no stale frame), useLayoutEffect for DOM measurement, re-keyed memos, or initial state.
  • Legitimate effect-bound cases → scoped disable with a per-site reason (async I/O loads, timers, DOM reads, external-event sync, open/close + focus-reset transitions).

Rules adopted

Rule Sites Resolution
react-hooks/immutability 1 Tiptap extension storage is intentionally mutable — scoped disable
react-hooks/preserve-manual-memoization 1 Dropped a navigator-reading useMemo the compiler can't preserve; hoisted pure logic
react-hooks/refs 12 Latest-value ref syncs → post-commit effects; popovers → layout-effect positioning; OutlineView → re-keyed memo; 2 unmount-flush refs kept render-synced (#755) with scoped disables
react-hooks/set-state-in-effect 33 ~10 refactored to render-phase / layout / initial state; the rest scoped-disabled with reasons

exhaustive-deps stays at warn (its v5 level; ~67 unrelated sites out of scope).

Verification

Full pnpm check:all is green — lint chain, file-size (reclaimed added lines by condensing comments to respect the ratchet-down baseline), knip, coverage thresholds met, sidecar (185), content-server (142), build, eager-chunk, size. No behavior changes; all affected component/hook test suites pass.

Closes #1063

xiaolai added 6 commits June 27, 2026 16:16
The single violation is a false positive: editor.storage is Tiptap's
intentionally-mutable extension storage bag, and writing the show-invisibles
flag there is the documented way to toggle an extension's runtime state — not
React-owned state. Scoped-disable with a reason rather than a refactor.
The revealLabel useMemo read the navigator global, which the React Compiler
cannot preserve. Hoisted the pure platform-to-key logic to a module function
and dropped the manual useMemo — the compiler auto-memoizes the component, so
the memo was redundant. menuLabels keeps its (preservable) useMemo.
Eliminate render-phase ref access (not concurrent-safe) across 12 sites:

- Latest-value ref syncs moved into post-commit effects (the React-recommended
  pattern), where the refs are read only from callbacks/effects/listeners:
  TiptapEditor, SourceEditor, SourcePane, useContentSearchScheduler,
  useTerminalSessions, useContentServer, useGenieInvocation, useTabDragOut.
- McpHistoryButton + WordCountPopover: measure the anchor in a layout effect and
  store the position in state, instead of reading a sibling DOM rect during render.
- OutlineView: re-key the headings useMemo on headingLinesKey (dropping the
  cache-ref pattern), preserving referential stability without render-phase refs.
- TiptapEditor editorRef + flushToStoreRef stay render-synced with scoped
  disables — the unmount-flush path (#755) needs them set before passive effects
  could run.
Resolve all 33 sites across 25 files, completing the react-hooks-7 adoption.

Genuine 'adjust state during render' cases refactored to render-phase setState
(React's recommended alternative — no extra render, no stale frame):
- selection clamps on list shrink (QuickOpen, GeniePicker, HeadingPicker)
- reset-selection-on-query (CommandPalette, via prev-value tracking)
- xterm activation latch (TerminalPanel)
- dev-section/section guards (Settings, AboutSettings)
- mount focus init via initial state (FileExplorer ContextMenu)

Legitimate effect-bound cases carry a scoped disable with a per-site reason —
async I/O loads (KbGraphView, HistoryView, useMcpClients/Server, useActionMetadata,
mermaid, PdfExportPage, settings loaders), timers (useAutoSaveDisplay), DOM
measurement (HeadingPicker position/portal), external-event sync (GeniePicker
prompt history, UniversalToolbar store/focus, useStatusBarTabDrag), and
open/close + focus-reset transitions (CommandPalette, GeniePicker, QuickOpen,
ImageContextMenu, TabContextMenu).

The four React-Compiler rules now inherit 'error' from v7 recommended; the
deferral block in eslint.config.js is replaced with the adoption rationale.
The react-hooks-7 adoption added scoped-disable comments and effect/refactor
scaffolding to several baselined files, pushing them past their frozen size.
Reclaim the lines by condensing comments (and using inline-reason single-line
disables for single-ref/single-setState sites) — no behavior change.
Cross-model audit (Codex) of the react-hooks-7 adoption flagged three
behavior-timing regressions; all fixed and re-verified:

- useContentSearchScheduler: revert excludeFoldersRef to a render-phase sync
  (scoped disable) so an already-pending debounced search reads the latest
  exclusions after an exclusion-only re-render, matching prior behavior.
- WordCountPopover + McpHistoryButton: the layout-effect positioning now
  remeasures every render while open (no deps), so the popover tracks trigger
  shifts as counts/badges change — as the original render-phase position()
  did. A functional setState updater returns prev when unchanged, preventing
  any render loop.
@xiaolai
xiaolai merged commit 4b45a1f into main Jun 27, 2026
7 checks passed
@xiaolai
xiaolai deleted the chore/react-hooks-7-adoption-1063 branch June 27, 2026 10:05
xiaolai added a commit that referenced this pull request Aug 12, 2026
…1063) (#1068)

* chore(lint): adopt react-hooks/immutability rule (#1063)

The single violation is a false positive: editor.storage is Tiptap's
intentionally-mutable extension storage bag, and writing the show-invisibles
flag there is the documented way to toggle an extension's runtime state — not
React-owned state. Scoped-disable with a reason rather than a refactor.

* chore(lint): adopt react-hooks/preserve-manual-memoization rule (#1063)

The revealLabel useMemo read the navigator global, which the React Compiler
cannot preserve. Hoisted the pure platform-to-key logic to a module function
and dropped the manual useMemo — the compiler auto-memoizes the component, so
the memo was redundant. menuLabels keeps its (preservable) useMemo.

* chore(lint): adopt react-hooks/refs rule (#1063)

Eliminate render-phase ref access (not concurrent-safe) across 12 sites:

- Latest-value ref syncs moved into post-commit effects (the React-recommended
  pattern), where the refs are read only from callbacks/effects/listeners:
  TiptapEditor, SourceEditor, SourcePane, useContentSearchScheduler,
  useTerminalSessions, useContentServer, useGenieInvocation, useTabDragOut.
- McpHistoryButton + WordCountPopover: measure the anchor in a layout effect and
  store the position in state, instead of reading a sibling DOM rect during render.
- OutlineView: re-key the headings useMemo on headingLinesKey (dropping the
  cache-ref pattern), preserving referential stability without render-phase refs.
- TiptapEditor editorRef + flushToStoreRef stay render-synced with scoped
  disables — the unmount-flush path (#755) needs them set before passive effects
  could run.

* chore(lint): adopt react-hooks/set-state-in-effect rule (#1063)

Resolve all 33 sites across 25 files, completing the react-hooks-7 adoption.

Genuine 'adjust state during render' cases refactored to render-phase setState
(React's recommended alternative — no extra render, no stale frame):
- selection clamps on list shrink (QuickOpen, GeniePicker, HeadingPicker)
- reset-selection-on-query (CommandPalette, via prev-value tracking)
- xterm activation latch (TerminalPanel)
- dev-section/section guards (Settings, AboutSettings)
- mount focus init via initial state (FileExplorer ContextMenu)

Legitimate effect-bound cases carry a scoped disable with a per-site reason —
async I/O loads (KbGraphView, HistoryView, useMcpClients/Server, useActionMetadata,
mermaid, PdfExportPage, settings loaders), timers (useAutoSaveDisplay), DOM
measurement (HeadingPicker position/portal), external-event sync (GeniePicker
prompt history, UniversalToolbar store/focus, useStatusBarTabDrag), and
open/close + focus-reset transitions (CommandPalette, GeniePicker, QuickOpen,
ImageContextMenu, TabContextMenu).

The four React-Compiler rules now inherit 'error' from v7 recommended; the
deferral block in eslint.config.js is replaced with the adoption rationale.

* chore(lint): keep #1063-edited files within file-size baseline

The react-hooks-7 adoption added scoped-disable comments and effect/refactor
scaffolding to several baselined files, pushing them past their frozen size.
Reclaim the lines by condensing comments (and using inline-reason single-line
disables for single-ref/single-setState sites) — no behavior change.

* fix(lint): preserve timing/repositioning behavior after #1063 refactors

Cross-model audit (Codex) of the react-hooks-7 adoption flagged three
behavior-timing regressions; all fixed and re-verified:

- useContentSearchScheduler: revert excludeFoldersRef to a render-phase sync
  (scoped disable) so an already-pending debounced search reads the latest
  exclusions after an exclusion-only re-render, matching prior behavior.
- WordCountPopover + McpHistoryButton: the layout-effect positioning now
  remeasures every render while open (no deps), so the popover tracks trigger
  shifts as counts/badges change — as the original render-phase position()
  did. A functional setState updater returns prev when unchanged, preventing
  any render loop.
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.

Adopt eslint-plugin-react-hooks v7 React-Compiler rules (deferred in #1062)

1 participant