-
Notifications
You must be signed in to change notification settings - Fork 3.5k
[No QA] ai-reviewer: rules for React's escape hatches #81651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -522,6 +522,222 @@ const results = items.map((item) => { | |
|
|
||
| --- | ||
|
|
||
| ### [PERF-14] Use `useSyncExternalStore` for external store subscriptions | ||
|
|
||
| - **Search patterns**: `addEventListener`, `subscribe`, `useEffect`, `useState` | ||
|
|
||
| - **Condition**: Flag ONLY when ALL of these are true: | ||
|
|
||
| - A `useEffect` subscribes to an external source (DOM events, third-party store, browser API) | ||
Julesssss marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| - The Effect's only purpose is to read a value from that source and write it into state via `setState` | ||
| - The pattern follows: subscribe in setup, unsubscribe in cleanup, `setState` in the listener | ||
|
|
||
| **DO NOT flag if:** | ||
|
|
||
| - The subscription triggers side effects beyond reading a value (e.g., logging, navigation) | ||
| - The external API doesn't fit the `subscribe` / `getSnapshot` contract | ||
| - The code already uses `useSyncExternalStore` | ||
| - The subscription is managed by a library (e.g., Onyx's `useOnyx`) | ||
|
|
||
| - **Reasoning**: [`useSyncExternalStore`](https://react.dev/learn/you-might-not-need-an-effect#subscribing-to-an-external-store) is React's purpose-built hook for reading external store values. It handles concurrent rendering edge cases (tearing), avoids the extra render pass of `useEffect` + `setState`, and makes the subscription contract explicit. Manual subscriptions via `useEffect` are more error-prone and miss these guarantees. | ||
|
|
||
| Good: | ||
|
|
||
| ```tsx | ||
| function subscribe(callback) { | ||
| window.addEventListener('online', callback); | ||
| window.addEventListener('offline', callback); | ||
| return () => { | ||
| window.removeEventListener('online', callback); | ||
| window.removeEventListener('offline', callback); | ||
| }; | ||
| } | ||
|
|
||
| function useOnlineStatus() { | ||
| // ✅ Good: Subscribing to an external store with a built-in Hook | ||
| return useSyncExternalStore( | ||
| subscribe, // React won't resubscribe for as long as you pass the same function | ||
| () => navigator.onLine, // How to get the value on the client | ||
| () => true // How to get the value on the server | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: It's prolly just general advice, but are we running something on the server?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI I have 1:1 mapped an example straight out of React's docs, these are backlinked in the reasoning section. It's not ours, but a comment on useSyncExternalStore's API.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thought so! However, this wouldn't be used in our codebase at all, so reviewer does not need to be aware of that. At the end of the day, it's one line more to digest with info that can be over-interpreted when hallucinations kick in. Again, nothing hard-blocking. Thanks for the rule in general ❤️
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah oki, fair. I'd leave it as-is and remove as soon as it becomes problematic. thanks |
||
| ); | ||
| } | ||
|
|
||
| function ChatIndicator() { | ||
| const isOnline = useOnlineStatus(); | ||
| // ... | ||
| } | ||
| ``` | ||
|
|
||
| Bad: | ||
|
|
||
| ```tsx | ||
| function useOnlineStatus() { | ||
| // Not ideal: Manual store subscription in an Effect | ||
| const [isOnline, setIsOnline] = useState(true); | ||
| useEffect(() => { | ||
| function updateState() { | ||
| setIsOnline(navigator.onLine); | ||
| } | ||
|
|
||
| updateState(); | ||
|
|
||
| window.addEventListener('online', updateState); | ||
| window.addEventListener('offline', updateState); | ||
| return () => { | ||
| window.removeEventListener('online', updateState); | ||
| window.removeEventListener('offline', updateState); | ||
| }; | ||
| }, []); | ||
| return isOnline; | ||
| } | ||
|
|
||
| function ChatIndicator() { | ||
| const isOnline = useOnlineStatus(); | ||
| // ... | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### [PERF-15] Clean up async Effects to prevent race conditions | ||
|
|
||
| - **Search patterns**: `useEffect`, `fetch(`, `async`, `await`, `.then(`, `setState`, `eslint-disable` | ||
|
|
||
| - **Condition**: Flag when EITHER of these is true: | ||
|
|
||
| **Case 1 — Missing cleanup:** | ||
| - A `useEffect` performs async work (fetch, promise chain, async/await) | ||
| - The async result is written to state via `setState` | ||
| - There is no cleanup mechanism to discard stale responses (no `ignore` flag, no `AbortController`, no cancellation token) | ||
|
|
||
| **Case 2 — Suppressed dependency lint:** | ||
| - A `useEffect` performs async work and sets state | ||
| - The dependency array has an `eslint-disable` comment suppressing `react-hooks/exhaustive-deps` | ||
| - This hides a dependency that could change and cause a race condition | ||
|
|
||
| **DO NOT flag if:** | ||
|
|
||
| - The Effect includes an `ignore`/`cancelled` boolean checked before `setState` | ||
| - The Effect uses `AbortController` to cancel the request on cleanup | ||
| - The dependency array is empty `[]` with no suppressed lint (no race possible — deps never change) | ||
| - The async operation doesn't set state (fire-and-forget) | ||
| - Data fetching is handled by a library/framework (e.g., Onyx, React Query) | ||
|
|
||
| - **Reasoning**: When an Effect's dependencies change, the previous async operation [may still be in flight](https://react.dev/learn/you-might-not-need-an-effect#fetching-data). Without cleanup, a slow earlier response can overwrite the result of a faster later response, showing stale data. This is especially dangerous for search inputs and navigation where dependencies change rapidly. | ||
|
|
||
| Good (ignore flag): | ||
|
|
||
| ```tsx | ||
| useEffect(() => { | ||
| let ignore = false; | ||
|
|
||
| fetchResults(query).then((json) => { | ||
| if (!ignore) { | ||
| setResults(json); | ||
| } | ||
| }); | ||
|
|
||
| return () => { | ||
| ignore = true; | ||
| }; | ||
| }, [query]); | ||
| ``` | ||
|
|
||
| Good (AbortController): | ||
adhorodyski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| ```tsx | ||
| useEffect(() => { | ||
| const controller = new AbortController(); | ||
|
|
||
| fetch(`/api/search?q=${query}`, {signal: controller.signal}) | ||
| .then((res) => res.json()) | ||
| .then((data) => setResults(data)) | ||
| .catch((e) => { | ||
| if (e.name !== 'AbortError') { | ||
| setError(e); | ||
| } | ||
| }); | ||
|
|
||
| return () => controller.abort(); | ||
| }, [query]); | ||
| ``` | ||
|
|
||
| Bad: | ||
|
|
||
| ```tsx | ||
| useEffect(() => { | ||
| fetchResults(query).then((json) => { | ||
| setResults(json); | ||
| }); | ||
| }, [query]); | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### [PERF-16] Guard initialization logic against double-execution | ||
|
|
||
| - **Search patterns**: `useEffect` with empty dependency array `[]` containing initialization logic (API calls, storage access, authentication checks, SDK setup, global configuration, event handler registration) | ||
|
|
||
| - **Condition**: Flag ONLY when ALL of these are true: | ||
|
|
||
| - A `useEffect` with empty dependency array `[]` runs non-idempotent initialization logic | ||
| - The logic would cause problems if executed twice (e.g., double API calls, invalidated tokens, duplicate SDK init, duplicate analytics sessions, duplicate deep link registrations) | ||
| - There is no guard mechanism (module-level flag or module-level execution) | ||
| - This applies at any level — app-wide init, feature screens, or individual components | ||
|
|
||
| **DO NOT flag if:** | ||
|
|
||
| - A module-level guard variable prevents double execution (`if (didInit) return`) | ||
| - The logic is idempotent (safe to run twice with no side effects) | ||
| - The logic is at module level, outside any component | ||
| - The Effect has non-empty dependencies (not one-time init) | ||
|
|
||
| - **Reasoning**: React Strict Mode [intentionally double-invokes Effects](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application) in development to surface missing cleanup. Non-idempotent initialization — whether app-wide (auth tokens, global config) or per-feature (SDK setup, analytics session creation, deep link handler registration) — can break when executed twice. Guarding with a module-level flag or moving initialization outside the component ensures it runs exactly once regardless of rendering mode. | ||
|
|
||
| Good (module-level guard): | ||
|
|
||
| ```tsx | ||
| let didInit = false; | ||
|
|
||
| function App() { | ||
| useEffect(() => { | ||
| if (didInit) { | ||
| return; | ||
| } | ||
| didInit = true; | ||
|
|
||
| loadDataFromLocalStorage(); | ||
| checkAuthToken(); | ||
| }, []); | ||
| } | ||
| ``` | ||
|
|
||
| Good (module-level execution): | ||
|
|
||
| ```tsx | ||
| if (typeof window !== 'undefined') { | ||
| checkAuthToken(); | ||
| loadDataFromLocalStorage(); | ||
| } | ||
|
|
||
| function App() { | ||
| // ... | ||
| } | ||
| ``` | ||
|
|
||
| Bad: | ||
|
|
||
| ```tsx | ||
| function App() { | ||
| useEffect(() => { | ||
| loadDataFromLocalStorage(); | ||
| checkAuthToken(); | ||
| }, []); | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### [CONSISTENCY-1] Avoid platform-specific checks within components | ||
|
|
||
| - **Search patterns**: `Platform.OS`, `isAndroid`, `isIOS`, `Platform\.select` | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems quite vague, could we narrow this down at all?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried that, but decided to focus on good conditions so it filters out false positives based on more a more nuanced description. I'd leave it as is and improve on as we go if it proves to be too little.