diff --git a/.claude/agents/code-inline-reviewer.md b/.claude/agents/code-inline-reviewer.md index 487bba3a6977d..743407cd2cf86 100644 --- a/.claude/agents/code-inline-reviewer.md +++ b/.claude/agents/code-inline-reviewer.md @@ -10,1700 +10,53 @@ model: inherit You are a **React Native Expert** — an AI trained to evaluate code contributions to Expensify and create inline comments for specific violations. -Your job is to scan through changed files and create **inline comments** for specific violations based on the below rules. +Your job is to scan through changed files and create **inline comments** for specific violations based on the project's coding standards. ## Rules -Each rule includes: +Coding standards are defined as individual rule files in `.claude/skills/coding-standards/rules/`. -- A unique **Rule ID** -- **Search patterns**: Grep patterns to efficiently locate potential violations in large files -- **Pass/Fail condition** -- **Reasoning**: Technical explanation of why the rule is important -- Examples of good and bad usage - -### [PERF-1] No spread in list item's renderItem - -- **Search patterns**: `renderItem`, `...` (look for both in proximity) - -- **Condition**: Flag ONLY when ALL of these are true: - - - Code is inside a renderItem function (function passed to FlatList, SectionList, etc.) - - A spread operator (...) is used on an object - - That object is being passed as a prop to a component - - The spread creates a NEW object literal inline - - **DO NOT flag if:** - - - Spread is used outside renderItem - - Spread is on an array - - Object is created once outside renderItem and reused - - Spread is used to clone for local manipulation (not passed as prop) - -- **Reasoning**: `renderItem` functions execute for every visible list item on each render. Creating new objects with spread operators forces React to treat each item as changed, preventing reconciliation optimizations and causing unnecessary re-renders of child components. - -Good: - -```tsx - -``` - -Bad: - -```tsx - -``` - ---- - -### [PERF-2] Return early before expensive work - -- **Search patterns**: Function bodies where `if (!param)` or `if (param === undefined)` appears AFTER function calls that use `param` - -- **Condition**: Flag ONLY when ALL of these are true: - - - Code performs expensive work (function calls, iterations, API/Onyx reads) - - A simple check could short-circuit earlier - - The simple check happens AFTER the expensive work - - **DO NOT flag if**: - - - Simple checks already come first - - Validation requires the computed result - - Expensive work must run for side effects - -- **Reasoning**: Early returns prevent wasted computation. Validate inputs before passing them to expensive operations. - -Good: - -```ts -function clearReportActionErrors(reportID: string, reportAction: ReportAction) { - if (!reportAction?.reportActionID) { - return; - } - - const originalReportID = getOriginalReportID(reportID, reportAction); - // ... -} -``` - -Bad: - -```ts -function clearReportActionErrors(reportID: string, reportAction: ReportAction) { - const originalReportID = getOriginalReportID(reportID, reportAction); - - if (!reportAction?.reportActionID) { - return; - } - // ... -} -``` - ---- - -### [PERF-3] Use OnyxListItemProvider hooks instead of useOnyx in renderItem - -- **Search patterns**: `useOnyx` within components used in `renderItem` - -- **Condition**: Components rendered inside `renderItem` functions should use dedicated hooks from `OnyxListItemProvider` instead of individual `useOnyx` calls. -- **Reasoning**: Individual `useOnyx` calls in renderItem create separate subscriptions for each list item, causing memory overhead and update cascades. `OnyxListItemProvider` hooks provide optimized data access patterns specifically designed for list rendering performance. - -Good: - -```tsx -const personalDetails = usePersonalDetails(); -``` - -Bad: - -```tsx -const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); -``` - ---- - -### [PERF-5] Use shallow comparisons instead of deep comparisons - -- **Search patterns**: `React.memo`, `deepEqual` - -- **Condition**: In `React.memo` and similar optimization functions, compare only specific relevant properties instead of using deep equality checks. -- **Reasoning**: Deep equality checks recursively compare all nested properties, creating performance overhead that often exceeds the re-render cost they aim to prevent. Shallow comparisons of specific relevant properties provide the same optimization benefits with minimal computational cost. - -Good: - -```tsx -memo(ReportActionItem, (prevProps, nextProps) => - prevProps.report.type === nextProps.report.type && - prevProps.report.reportID === nextProps.report.reportID && - prevProps.isSelected === nextProps.isSelected -) -``` - -Bad: - -```tsx -memo(ReportActionItem, (prevProps, nextProps) => - deepEqual(prevProps.report, nextProps.report) && - prevProps.isSelected === nextProps.isSelected -) -``` - ---- - -### [PERF-6] Derive state from props - -- **Condition**: Flag when useEffect updates state based on props or other state, when the value could be computed directly - -- **Reasoning**: Computing derived values directly in the component body ensures they're always synchronized with props/state and avoids unnecessary re-renders. - -Good: - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor'); - const [lastName, setLastName] = useState('Swift'); - - // ✅ Good: calculated during rendering - const fullName = firstName + ' ' + lastName; -} -``` - -Bad: - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor'); - const [lastName, setLastName] = useState('Swift'); - - // 🔴 Avoid: redundant state and unnecessary Effect - const [fullName, setFullName] = useState(''); - useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); -} -``` - ---- - -### [PERF-7] Control component resets via key prop - -- **Condition**: - - Flag when useEffect resets all or most component state when a prop changes - - Should use `key` prop instead to reset the entire component - -- **Reasoning**: Using `key` prop for full resets is more React-idiomatic. When a prop changes and you need to reset all component state, the `key` prop causes React to unmount and remount the component, automatically resetting all state without needing useEffect. - -Good: - -```tsx -function ProfilePage({ userId }) { - return ; -} - -function ProfileView({ userId }) { - const [comment, setComment] = useState(''); - const [rating, setRating] = useState(0); - // Component resets when userId changes due to key prop -} -``` - -Bad: - -```tsx -// 🔴 Avoid: resetting all state with useEffect -function ProfilePage({ userId }) { - return ; -} - -function ProfileView({ userId }) { - const [comment, setComment] = useState(''); - const [rating, setRating] = useState(0); - - useEffect(() => { - setComment(''); // Reset when userId changes - setRating(0); - }, [userId]); -} -``` - ---- - -### [PERF-8] Handle events in event handlers - -- **Condition**: Flag when useEffect responds to user events that should be handled in event handlers - -- **Reasoning**: Event handlers provide immediate response and clearer code flow. useEffect adds unnecessary render cycles and makes the relationship between user action and response less clear. - -Good: - -```tsx -function BuyButton({ productId, onBuy }) { - function handleClick() { - // ✅ Good: handle event directly in event handler - onBuy(); - showNotification('Item purchased!'); - } - - return ; -} -``` - -Bad: - -```tsx -function BuyButton({ productId, onBuy }) { - const [isBuying, setIsBuying] = useState(false); - - // 🔴 Avoid: handling events in useEffect - useEffect(() => { - if (isBuying) { - onBuy(); - showNotification('Item purchased!'); - } - }, [isBuying, onBuy]); - - return ; -} -``` - ---- - -### [PERF-9] Avoid useEffect chains - -- **Condition**: Flag when multiple useEffects form a chain where one effect's state update triggers another effect - -- **Reasoning**: Chains of effects create complex dependencies, timing issues, and unnecessary renders. Logic should be restructured to avoid interdependent effects. - -Good: - -```tsx -function Form() { - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - - // ✅ Good: compute derived values directly - const fullName = firstName + ' ' + lastName; - const isValid = firstName.length > 0 && lastName.length > 0; - - return ( -
- setFirstName(e.target.value)} /> - setLastName(e.target.value)} /> - {isValid && } -
- ); -} -``` - -Bad: - -```tsx -function Form() { - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [fullName, setFullName] = useState(''); - const [isValid, setIsValid] = useState(false); - - // 🔴 Avoid: chain of effects - useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); - - useEffect(() => { - setIsValid(fullName.length > 0); - }, [fullName]); -} -``` - ---- - -### [PERF-10] Communicate with parent components without useEffect - -- **Condition**: Flag when useEffect calls parent callbacks to communicate state changes or pass data to parent components - -- **Reasoning**: Parent-child communication should not use useEffect. Instead, lift the state up to the parent component and pass it down as props. This follows React's unidirectional data flow pattern, eliminates synchronization issues, reduces unnecessary renders, and makes the data flow clearer. Use useEffect only when synchronizing with external systems, not for parent-child communication. - -Good: - -```tsx -// Lifting state up -function Parent() { - const [value, setValue] = useState(''); - return ; -} - -function Child({ value, onChange }) { - return onChange(e.target.value)} />; -} -``` - -Bad: - -```tsx -// 🔴 Avoid: passing data via useEffect -function Child({ onValueChange }) { - const [value, setValue] = useState(''); - - useEffect(() => { - onValueChange(value); - }, [value, onValueChange]); - - return setValue(e.target.value)} />; -} -``` - ---- - -### [PERF-11] Optimize data selection and handling - -- **Search patterns**: `useOnyx`, `selector`, `.filter(`, `.map(` - -- **Condition**: Flag ONLY when ALL of these are true: - - - A component uses a broad data structure (e.g., entire object) without selecting specific fields - - This causes unnecessary re-renders when unrelated fields change - - OR unnecessary data filtering/fetching is performed (excluding necessary data, fetching already available data) - - **DO NOT flag if:** - - - Specific fields are already being selected or the data structure is static - - The filtering is necessary for correct functionality - - The fetched data is required and cannot be derived from existing data - - The function requires the entire object for valid operations - -- **Reasoning**: Using broad data structures or performing unnecessary data operations causes excessive re-renders and degrades performance. Selecting specific fields and avoiding redundant operations reduces render cycles and improves efficiency. - -Good: - -```tsx -function UserProfile({ userId }) { - const [user] = useOnyx(`${ONYXKEYS.USER}${userId}`, { - selector: (user) => ({ - name: user?.name, - avatar: user?.avatar, - }), - }); - return {user?.name}; -} -``` - -Bad: - -```tsx -function UserProfile({ userId }) { - const [user] = useOnyx(`${ONYXKEYS.USER}${userId}`); - // Component re-renders when any user field changes, even unused ones - return {user?.name}; -} -``` - ---- - -### [PERF-12] Prevent memory leaks in components and plugins - -- **Search patterns**: `setInterval`, `setTimeout`, `addEventListener`, `subscribe`, `useEffect` with missing cleanup - -- **Condition**: Flag ONLY when ALL of these are true: - - - A resource (timeout, interval, event listener, subscription, etc.) is created in a component - - The resource is not cleared upon component unmount - - Asynchronous operations are initiated without a corresponding cleanup mechanism - - **DO NOT flag if:** - - - The resource is cleared properly in a cleanup function (e.g., inside `useEffect` return) - - The resource is not expected to persist beyond the component's lifecycle - - The resource is managed by a library that handles cleanup automatically - - The operation is guaranteed to complete before the component unmounts - -- **Reasoning**: Failing to clear resources causes memory leaks, leading to increased memory consumption and potential crashes, especially in long-lived components or components that mount/unmount frequently. - -Good: - -```tsx -function TimerComponent() { - useEffect(() => { - const intervalId = setInterval(() => { - updateTimer(); - }, 1000); - - return () => { - clearInterval(intervalId); - }; - }, []); - - return Timer; -} -``` - -Bad: - -```tsx -function TimerComponent() { - useEffect(() => { - const intervalId = setInterval(() => { - updateTimer(); - }, 1000); - // Missing cleanup - interval will continue after unmount - }, []); - - return Timer; -} -``` - ---- - -### [PERF-13] Avoid iterator-independent function calls in array methods - -- **Search patterns**: `.map(`, `.reduce(`, `.filter(`, `.some(`, `.every(`, `.find(`, `.findIndex(`, `.flatMap(`, `.forEach(` - -- **Condition**: Flag when ALL of these are true: - - **For side-effect-free methods** (`.map`, `.reduce`, `.filter`, `.some`, `.every`, `.find`, `.findIndex`, `.flatMap`): - - A function call exists inside the callback - - The function call does NOT receive: - - The iterator variable directly (e.g., `transform(item)`) - - A property/value derived from the iterator (e.g., `format(item.name)`) - - The index parameter when used meaningfully (e.g., `generateId(index)`) - - The function is not a method called ON the iterator or iterator-derived value (e.g., `item.getValue()`) - - **For `.forEach`**: - - Same conditions as above, BUT also verify the side effect doesn't depend on iteration context - - If the function call would produce the same effect regardless of which iteration it runs in, flag it - - **DO NOT flag if:** - - - Function uses iterator, its parts or derived value based on iterator (e.g. `func(item.process())`) - - Function call depends on iterator (e.g. `item.value ?? getDefault()`) - - Function is used when mapping to new entities (e.g. `const thing = { id: createID() }`) - - Above but applied to index instead of iterator - -- **Reasoning**: Function calls inside iteration callbacks that don't use the iterator variable execute redundantly - producing the same result. This creates O(n) overhead that scales with data size. Hoisting these calls outside the loop eliminates redundant computation and improves performance, especially on large datasets like transaction lists or report collections. - -Good: - -```ts -// Hoist iterator-independent calls outside the loop -const config = getConfig(); - -const results = items.map((item) => item.value * config.multiplier); -``` - -```ts -// Function receives iterator or iterator-derived value -const formatted = items.map((item) => formatCurrency(item.amount)); -``` - -```ts -// Index used meaningfully -const indexed = items.map((item, index) => ({ ...item, id: generateId(index) })); -``` - -Bad: - -```ts -// getConfig() called on every iteration but doesn't use item -const results = items.map((item) => { - const config = getConfig(); - return item.value * config.multiplier; -}); -``` - ---- - -### [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) - - Inside the listener, at least one `setState` call directly mirrors an external value - (e.g., `setWidth(window.innerWidth)`, `setOnline(navigator.onLine)`) - - The pattern for that state variable follows: subscribe in setup, unsubscribe in cleanup, - `setState(externalValue)` in the listener - - Evaluate each state variable independently — if one state variable is a raw mirror of an - external value, flag it even if the same effect also manages other state for different purposes - - **DO NOT flag if:** - - - The specific state variable being flagged undergoes transformation, debouncing, or derives - from computation rather than directly mirroring the external value. Other state variables - in the same effect that DO directly mirror external values should still be flagged. - - 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 - ); -} - -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 callback performs side effects (setState, navigation, data mutations, deletions) - - 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 triggers side effects (setState, navigation, mutations) - - 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 async operation is truly fire-and-forget (no setState, no navigation, no mutations — - just logging or analytics that are safe to complete after unmount) - - The dependency array is empty `[]` with no suppressed lint, AND the async callback only - performs idempotent/safe operations (no navigation, no destructive mutations that could - fire after unmount) - - 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): - -```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 or ref-based guard variable prevents double execution. A proper execution - guard follows this pattern: `if (didInit) return; didInit = true;` — it checks a flag AND - sets it. Conditional checks on data/props (e.g., `if (!transaction) return`, - `if (action !== 'CREATE') return`) are NOT execution guards — they validate preconditions - but don't prevent the logic from running again if the same preconditions hold in a second - invocation (which happens in React Strict Mode). - - The logic is idempotent (safe to run twice with no side effects) - - NOTE: Navigation calls (e.g., `navigate()`), data deletion (e.g., `removeDraftTransactions()`), - and similar mutations are NOT idempotent — running them twice produces different/undesirable results. - - 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` - -- **Condition**: Flag ONLY when ALL of these are true: +Each rule file contains: - - Platform detection checks (e.g., `Platform.OS`, `isAndroid`, `isIOS`) are present within a component - - The checks lead to hardcoded values or styles specific to a platform - - The component is not structured to handle platform-specific logic through file extensions or separate components - - **DO NOT flag if:** - - - The logic is handled through platform-specific file extensions (e.g., `index.web.tsx`, `index.native.tsx`) - -- **Reasoning**: Mixing platform-specific logic within components increases maintenance overhead, complexity, and bug risk. Separating concerns through dedicated files or components improves maintainability and reduces platform-specific bugs. - -Good: - -```tsx -// Platform-specific file: Button.desktop.tsx -function Button() { - return ; +} + +// Parent wires siblings together + + formRef.current?.getData()} /> +``` + +### Correct + +#### Correct (abstract contract) + +- Interface exposes capability: "user can add a stop" +- Implementation details (index computation, navigation) stay with consumer +- Component is reusable in any context needing an "add stop" action + +```tsx + { + const nextIndex = Object.keys(transaction?.comment?.waypoints ?? {}).length; + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(..., nextIndex.toString(), ...)); + }} +/> + +// in DistanceRequestFooter + +``` + +#### Correct (independent contracts) + +- Each component has a self-contained interface +- State coordination happens at composition level + +```tsx +function EditProfile() { + const [formData, setFormData] = useState(); + return ( + <> + + API.save(formData)} /> + + ); +} +``` + +--- + +### Review Metadata + +#### Condition + +Flag ONLY when BOTH of these are true: + +1. A component's interface is shaped around a specific consumer's implementation rather than abstract capabilities +2. AND at least ONE of the following manifestations is present: + - The component receives data only to extract values for callbacks (doesn't use it for rendering) + - Callback signatures encode consumer-specific assumptions (e.g., `(index: number) => void` for navigation) + - The component accesses external state through refs or imperative handles + +**Signs of violation:** +- Callback signatures that encode consumer assumptions: `navigateToWaypoint(index: number)` instead of `onAddWaypoint()` +- Props passed only to extract values for callbacks, not for rendering (e.g., `transaction` passed just to compute `waypoints.length`) +- Imperative access to external state via refs or `useImperativeHandle` +- Component requires modification to work in a different context + +**DO NOT flag if:** +- Component signals events with data it naturally owns (e.g., `onChange(value)` for an input, `onSelectItem(item)` for a list) +- Callbacks are abstract actions the component can trigger (e.g., `onAddStop()`, `onSubmit()`) +- State coordination happens at a higher level with clear data flow + +**What makes a contract "abstract":** +- Callback describes *what happened* in component terms: `onAddStop`, `onSelect`, `onChange` +- Callback does NOT describe *what consumer should do*: `navigateToWaypoint(index)`, `updateParentState(value)` +- Props are used for rendering or internal logic, not just to compute callback arguments +- Component works without modification in a different context + +**Search Patterns** (hints for reviewers): +- `index: number` (in callback signatures) +- `useImperativeHandle` +- `ref` diff --git a/.claude/skills/coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md b/.claude/skills/coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md new file mode 100644 index 0000000000000..817ededb2e35a --- /dev/null +++ b/.claude/skills/coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md @@ -0,0 +1,169 @@ +--- +ruleId: CLEAN-REACT-PATTERNS-4 +title: Avoid side-effect spaghetti +--- + +## [CLEAN-REACT-PATTERNS-4] Avoid side-effect spaghetti + +### Reasoning + +When multiple unrelated responsibilities are grouped into a single component, hook, or utility, if any one concern changes, then unrelated logic must be touched as well, increasing coupling, regression risk, and cognitive load. This is the single responsibility principle for React: extract small units that do very little, very well. A component with several unrelated effects is a code smell - even a single effect can benefit from extraction to something with a good name, proper description, and isolated tests. + +**Bucketing questions for refactoring:** +1. Does this logic need the React render loop? YES -> Extract to a focused custom hook. NO -> Extract out of React entirely (e.g., Onyx migration, global initialization). +2. Does this logic need to be in this component? YES -> Keep it, but use a focused hook. NO -> Extract to a separate component that owns this concern. + +**Hook granularity guidance:** +- Group effects that serve the same purpose into one hook (e.g., all telemetry setup in `useTelemetry`) +- Group effects that can be reused together across components +- Don't create 15 separate single-effect hooks if 5 well-named grouped hooks make more sense + +### Incorrect + +#### Incorrect (side-effect spaghetti) + +- Component mixes session management, deep linking, telemetry, navigation, splash screen, audio, and other startup logic +- Several unrelated `useOnyx` calls and `useEffect` hooks in a single component +- Changing one concern risks breaking others + +```tsx +function Expensify() { + // Session & auth + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [session] = useOnyx(ONYXKEYS.SESSION); + + // Navigation & routing + const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); + const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); + const [isNavigationReady, setIsNavigationReady] = useState(false); + + // App state + const [updateAvailable] = useOnyx(ONYXKEYS.UPDATE_AVAILABLE); + const [updateRequired] = useOnyx(ONYXKEYS.UPDATE_REQUIRED); + const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED); + + // Splash screen + const {splashScreenState, setSplashScreenState} = useContext(SplashScreenStateContext); + + // ... 10+ more useOnyx calls for unrelated concerns ... + + // Telemetry effect + useEffect(() => { + bootsplashSpan.current = startSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.ROOT, {...}); + // ... + }, []); + + // Public room checking effect + useEffect(() => { + if (isCheckingPublicRoom) return; + endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.ONYX); + // ... + }, [isCheckingPublicRoom]); + + // Splash screen effect + useEffect(() => { + if (!shouldHideSplash) return; + startSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.SPLASH_HIDER, {...}); + }, [shouldHideSplash]); + + // Deep linking effect + useEffect(() => { + Linking.getInitialURL().then((url) => { + if (url) { + openReportFromDeepLink(url, ...); + } + }); + // ... + }, []); + + // Audio mode effect + useEffect(() => { + Audio.setAudioModeAsync({playsInSilentModeIOS: true}); + }, []); + + // ... 10+ more useEffects mixing concerns ... +} +``` + +In this example: +- The component handles telemetry, deep linking, audio, session, navigation, splash screen, and more +- Each concern is interleaved with others, making it hard to modify one without risking regression in another +- Effects could be extracted to focused hooks: `useTelemetrySpans`, `useDeepLinking`, `useAudioMode`, etc. +- Entry points don't get special treatment — extracting effects into named hooks improves clarity and makes it possible to understand what each effect does and how to safely modify it + +### Correct + +#### Correct (separated concerns) + +- Each piece of logic is extracted to a focused hook or component +- Parent component only orchestrates what to render +- State subscriptions in smaller components don't cause re-renders in parent +- Component-scoped hooks can be co-located in the same directory for maintainability + +```tsx +function DebugMenu() { + useDebugShortcut(); + + return ( + // Debug menu UI + ); +} + +function ParentComponent({ reportID }: { reportID: string }) { + return ( + + {/* Each child owns its own concerns */} + + + + ); +} +``` + +```tsx +// Focused hook that does one thing well +function useDebugShortcut() { + useEffect(() => { + const debugShortcutConfig = CONST.KEYBOARD_SHORTCUTS.DEBUG; + const unsubscribeDebugShortcut = KeyboardShortcut.subscribe( + debugShortcutConfig.shortcutKey, + () => toggleTestToolsModal(), + debugShortcutConfig.descriptionKey, + debugShortcutConfig.modifiers, + true, + ); + + return () => { + unsubscribeDebugShortcut(); + }; + }, []); +} +``` + +--- + +### Review Metadata + +#### Condition + +Flag when a component, hook, or utility aggregates multiple unrelated responsibilities in a single unit, making it difficult to modify one concern without touching others. + +**Signs of violation:** +- Component has several `useEffect` hooks handling unrelated concerns (e.g., telemetry, deep linking, audio, session management all in one component) +- A single `useEffect` or hook handles multiple distinct responsibilities +- Unrelated state variables are interdependent or updated together +- Logic mixes data fetching, navigation, UI state, and lifecycle behavior in one place +- Removing one piece of functionality requires careful untangling from others + +**What counts as "unrelated":** +- Group by responsibility (what the code does), NOT by timing (when it runs) +- Data fetching and analytics are NOT related — they serve different purposes even if both run on mount +- Session management and audio configuration are NOT related — different domains entirely + +**DO NOT flag if:** +- Component is a thin orchestration layer that ONLY composes child components (no business logic, no effects beyond rendering) +- Effects are extracted into focused custom hooks with single responsibilities (e.g., `useDebugShortcut`, `usePriorityMode`) — inline `useEffect` calls are a code smell and should be named hooks + +**Search Patterns** (hints for reviewers): +- `useEffect` +- `useOnyx` diff --git a/.claude/skills/coding-standards/rules/clean-react-5-narrow-state.md b/.claude/skills/coding-standards/rules/clean-react-5-narrow-state.md new file mode 100644 index 0000000000000..19f5d01cf8e36 --- /dev/null +++ b/.claude/skills/coding-standards/rules/clean-react-5-narrow-state.md @@ -0,0 +1,119 @@ +--- +ruleId: CLEAN-REACT-PATTERNS-5 +title: Keep state and subscriptions narrow +--- + +## [CLEAN-REACT-PATTERNS-5] Keep state and subscriptions narrow + +### Reasoning + +When unrelated pieces of data are grouped into a single state structure, if an unused part changes, then all consumers re-render unnecessarily. This silently expands render scope, increases coupling, and makes performance regressions hard to detect. Structuring state around cohesive concerns ensures render scope stays predictable and changes remain local. + +**Distinction from PERF-11**: PERF-11 addresses individual `useOnyx` selector usage. This rule addresses state structure — how multiple values are grouped and exposed to consumers via contexts, hooks, or stores. + +**Distinction from CLEAN-REACT-PATTERNS-2**: PATTERNS-2 addresses data flow direction — parent shouldn't fetch data just to pass to children. This rule addresses how state is structured and grouped within any state provider. + +### Incorrect + +#### Incorrect (grab-bag state — bundles unrelated concerns) + +- State provider subscribes to many unrelated Onyx collections +- Exposed value mixes navigation state, list data, membership data, and cache utilities +- Any consumer re-renders when ANY subscribed value changes + +```tsx +function SidebarOrderedReportsContextProvider({children}) { + // Many unrelated Onyx subscriptions bundled together + const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [reportsDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + + // Context value mixes unrelated concerns + const contextValue = { + orderedReports, // List data + orderedReportIDs, // List data + currentReportID, // Navigation state + policyMemberAccountIDs, // Policy membership + clearLHNCache, // Cache management utility + }; + + return {children}; +} + +// A component needing only currentReportID re-renders when orderedReports changes +// A component needing only policyMemberAccountIDs re-renders when navigation changes +``` + +### Correct + +#### Correct (cohesive state — all values serve one purpose) + +- All state relates to one concern (keyboard) +- Values change together — no wasted re-renders +- Derived state computed inline, not stored separately + +```tsx +type KeyboardStateContextValue = { + isKeyboardShown: boolean; + isKeyboardActive: boolean; + keyboardHeight: number; +}; + +function KeyboardStateProvider({children}: ChildrenProps) { + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isKeyboardActive, setIsKeyboardActive] = useState(false); + + useEffect(() => { + const showListener = KeyboardEvents.addListener('keyboardDidShow', (e) => { + setKeyboardHeight(e.height); + setIsKeyboardActive(true); + }); + const hideListener = KeyboardEvents.addListener('keyboardDidHide', () => { + setKeyboardHeight(0); + setIsKeyboardActive(false); + }); + return () => { + showListener.remove(); + hideListener.remove(); + }; + }, []); + + const contextValue = useMemo(() => ({ + keyboardHeight, + isKeyboardShown: keyboardHeight !== 0, // Derived, not separate state + isKeyboardActive, + }), [keyboardHeight, isKeyboardActive]); + + return {children}; +} +``` + +--- + +### Review Metadata + +#### Condition + +Flag when a state structure (context, hook, store, or subscription) bundles unrelated concerns together, causing consumers to re-render when data they don't use changes. + +**Signs of violation:** +- State provider (context, hook, or store) that bundles unrelated data (e.g., navigation state + list data + cache utilities in one structure) +- State object where properties serve different purposes and change independently +- Multiple unrelated subscriptions (`useOnyx`, `useContext`, store selectors) aggregated into a single exposed value +- Consumers of a state source that only use a subset of the provided values + +**DO NOT flag if:** +- State values are cohesive — they change together and serve the same purpose (e.g., `keyboardHeight` + `isKeyboardShown` both relate to keyboard state) +- The state structure is intentionally designed as an aggregation point and consumers use most/all values +- Individual `useOnyx` calls without selectors — this is covered by [PERF-11] + +**Search Patterns** (hints for reviewers): +- `Context` +- `Provider` +- `useOnyx` diff --git a/.claude/skills/coding-standards/rules/consistency-1-no-platform-checks.md b/.claude/skills/coding-standards/rules/consistency-1-no-platform-checks.md new file mode 100644 index 0000000000000..fe42131682d55 --- /dev/null +++ b/.claude/skills/coding-standards/rules/consistency-1-no-platform-checks.md @@ -0,0 +1,57 @@ +--- +ruleId: CONSISTENCY-1 +title: Avoid platform-specific checks within components +--- + +## [CONSISTENCY-1] Avoid platform-specific checks within components + +### Reasoning + +Mixing platform-specific logic within components increases maintenance overhead, complexity, and bug risk. Separating concerns through dedicated files or components improves maintainability and reduces platform-specific bugs. + +### Incorrect + +```tsx +function Button() { + const isAndroid = Platform.OS === 'android'; + return isAndroid ? ( + + ) : ( + ; +} +``` + +### Correct + +```tsx +function BuyButton({ productId, onBuy }) { + function handleClick() { + // Good: handle event directly in event handler + onBuy(); + showNotification('Item purchased!'); + } + + return ; +} +``` + +--- + +### Review Metadata + +Flag ONLY when ALL of these are true: + +- `useEffect` responds to user events +- The event handler is available (onClick, onChange, etc.) +- Logic could be moved directly to the event handler + +**DO NOT flag if:** + +- useEffect performs necessary setup/teardown not tied to events +- Event response depends on component state initialization +- Multiple events need the same handler and useEffect is cleaner + +**Search Patterns** (hints for reviewers): +- `useEffect` +- `useState` diff --git a/.claude/skills/coding-standards/rules/perf-9-no-useeffect-chains.md b/.claude/skills/coding-standards/rules/perf-9-no-useeffect-chains.md new file mode 100644 index 0000000000000..918ddb8f0c559 --- /dev/null +++ b/.claude/skills/coding-standards/rules/perf-9-no-useeffect-chains.md @@ -0,0 +1,71 @@ +--- +ruleId: PERF-9 +title: Avoid useEffect chains +--- + +## [PERF-9] Avoid useEffect chains + +### Reasoning + +Chains of effects create complex dependencies, timing issues, and unnecessary renders. Logic should be restructured to avoid interdependent effects. + +### Incorrect + +```tsx +function Form() { + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [fullName, setFullName] = useState(''); + const [isValid, setIsValid] = useState(false); + + // Avoid: chain of effects + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + useEffect(() => { + setIsValid(fullName.length > 0); + }, [fullName]); +} +``` + +### Correct + +```tsx +function Form() { + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + + // Good: compute derived values directly + const fullName = firstName + ' ' + lastName; + const isValid = firstName.length > 0 && lastName.length > 0; + + return ( + + setFirstName(e.target.value)} /> + setLastName(e.target.value)} /> + {isValid && } + + ); +} +``` + +--- + +### Review Metadata + +Flag ONLY when ALL of these are true: + +- Multiple `useEffect` hooks exist +- One effect's state update triggers another effect +- Logic could be combined or computed instead + +**DO NOT flag if:** + +- Effects handle different concerns (e.g., one for setup, one for event listening) +- Effects depend on external events that can't be combined +- Separation of concerns requires multiple effects + +**Search Patterns** (hints for reviewers): +- `useEffect` +- `useState` diff --git a/.github/scripts/extractAllowedRules.sh b/.github/scripts/extractAllowedRules.sh index af2007d18c3fa..645c3dae93996 100755 --- a/.github/scripts/extractAllowedRules.sh +++ b/.github/scripts/extractAllowedRules.sh @@ -1,26 +1,31 @@ #!/bin/bash -# Extract allowed rules from code-inline-reviewer.md -# Rules are in format [PREFIX-NUMBER] where PREFIX is uppercase letters +# Extract allowed rules from individual rule files in the rules directory. +# Each rule file has YAML frontmatter with a ruleId field. set -euo pipefail -REVIEWER_FILE="${1:-.claude/agents/code-inline-reviewer.md}" +RULES_DIR="${1:-.claude/skills/coding-standards/rules}" OUTPUT_FILE="${2:-.claude/allowed-rules.txt}" -if [[ ! -f "$REVIEWER_FILE" ]]; then - echo "Error: Reviewer file not found: $REVIEWER_FILE" >&2 +if [[ ! -d "$RULES_DIR" ]]; then + echo "Error: Rules directory not found: $RULES_DIR" >&2 exit 1 fi -# Extract rules in format [CAPS-NUMBER] (e.g., [PERF-1], [SEC-1], [STYLE-1]) -# Remove brackets to store as PERF-1, SEC-1, etc. -grep -oE '\[[A-Z]+-[0-9]+\]' "$REVIEWER_FILE" | sed 's/\[\(.*\)\]/\1/' | sort -u > "$OUTPUT_FILE" +# Extract ruleId from YAML frontmatter of each non-underscore .md file +# Frontmatter is between --- delimiters; ruleId is on its own line +true > "$OUTPUT_FILE" +for file in "$RULES_DIR"/[!_]*.md; do + [[ -f "$file" ]] || continue + grep -m1 '^ruleId:' "$file" | sed 's/^ruleId:[[:space:]]*//' >> "$OUTPUT_FILE" || true +done + +sort -u -o "$OUTPUT_FILE" "$OUTPUT_FILE" if [[ ! -s "$OUTPUT_FILE" ]]; then - echo "Error: No allowed rules found in $REVIEWER_FILE" >&2 + echo "Error: No allowed rules found in $RULES_DIR" >&2 exit 1 fi echo "Extracted allowed rules:" cat "$OUTPUT_FILE" - diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 543ffd303dcd1..2743b81a4e84d 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -42,10 +42,10 @@ jobs: run: | echo "$GITHUB_WORKSPACE/.claude/scripts" >> "$GITHUB_PATH" - - name: Extract allowed rules from code-inline-reviewer.md + - name: Extract allowed rules from coding standards run: | "$GITHUB_WORKSPACE/.github/scripts/extractAllowedRules.sh" \ - "$GITHUB_WORKSPACE/.claude/agents/code-inline-reviewer.md" \ + "$GITHUB_WORKSPACE/.claude/skills/coding-standards/rules" \ "$GITHUB_WORKSPACE/.claude/allowed-rules.txt" - name: Run Claude Code (code) diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index b61b678d00917..da18f59c5dce8 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -1016,7 +1016,7 @@ So, if a new language feature isn't something we have agreed to support it's off ## React Coding Standards -For additional React best practices and patterns that are automatically enforced during code review, see the [AI Code Reviewer Rules](../.claude/agents/code-inline-reviewer.md). +For additional React best practices and patterns that are automatically enforced during code review, see the [Coding Standards](../.claude/skills/coding-standards/SKILL.md). ### Code Documentation diff --git a/contributingGuides/philosophies/AI-REVIEWER.md b/contributingGuides/philosophies/AI-REVIEWER.md index 2cb76c5920353..d9b29dafd19c2 100644 --- a/contributingGuides/philosophies/AI-REVIEWER.md +++ b/contributingGuides/philosophies/AI-REVIEWER.md @@ -37,7 +37,7 @@ When AI feedback is unclear or ambiguous, contributors will benefit from discuss When AI feedback is incorrect or not applicable, reach out to the AI reviewer maintainers in the #expensify-open-source Slack channel to help improve the system. This feedback helps refine the reviewers and prevents the same issues from recurring. ### Keep rule documentation in sync with AI reviewer prompts -When adding or modifying rules in AI reviewer agent files, the corresponding documentation should be updated. The agent files in `.claude/agents/` are the source of truth for specific rules. +When adding or modifying rules, the corresponding documentation should be updated. The coding standard files in `.claude/skills/coding-standards/` are the source of truth for code review rules. ## Reviewer Setup @@ -46,7 +46,7 @@ When adding or modifying rules in AI reviewer agent files, the corresponding doc **code-inline-reviewer (Smart Linter)** - Reviews source code PRs for specific, predefined violations - Creates inline comments on lines that violate rules -- See `.claude/agents/code-inline-reviewer.md` for current rule definitions +- See `.claude/skills/coding-standards/` for current rule definitions **Holistic Reviewer** - Provides general code review without predefined rules