From 52c7d8d84448a64e45784a9bd5fe94bdf272e153 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Fri, 9 Jan 2026 23:25:15 +1000 Subject: [PATCH] fix: resolve new ESLint rules from eslint-plugin-react-hooks update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use useId() instead of Math.random() for stable ID generation in Checkbox and Switch - Use deterministic rotation/delay calculations instead of Math.random() in CategoryGrid and ProblemSection - Add eslint-disable comments for intentional setState in useEffect patterns: - Hydration safety (setMounted) - Initial localStorage sync - Route-based UI sync - Controlled component sync - Browser API initial sync (scroll position, media queries) - Add eslint-disable for ref merging pattern in AnimatedCounter and StatCard 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- apps/web/src/components/PackageManagerSelector.tsx | 8 ++++++-- apps/web/src/components/ThemeToggle.tsx | 3 ++- apps/web/src/components/dashboard/DashboardLayout.tsx | 8 +++++--- apps/web/src/components/home/CategoryGrid.tsx | 10 ++++++---- apps/web/src/components/home/ProblemSection.tsx | 4 ++-- packages/ui/src/AnimatedCounter/AnimatedCounter.tsx | 3 ++- packages/ui/src/Checkbox/Checkbox.tsx | 5 +++-- packages/ui/src/StatCard/StatCard.tsx | 3 ++- packages/ui/src/Switch/Switch.tsx | 5 +++-- packages/ui/src/system/hooks/useParallax.ts | 3 ++- packages/ui/src/system/hooks/useReducedMotion.ts | 3 ++- 11 files changed, 35 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/PackageManagerSelector.tsx b/apps/web/src/components/PackageManagerSelector.tsx index ce83a7b..2bb5cfb 100644 --- a/apps/web/src/components/PackageManagerSelector.tsx +++ b/apps/web/src/components/PackageManagerSelector.tsx @@ -26,19 +26,21 @@ export function PackageManagerSelector({ }: PackageManagerSelectorProps): React.ReactElement { const [selected, setSelected] = useState('npm'); - // Load from localStorage on mount + // Load from localStorage on mount - intentional initial sync from browser storage useEffect(() => { if (typeof window !== 'undefined') { const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null; if (stored && packageManagers.some((pm) => pm.id === stored)) { + // eslint-disable-next-line react-hooks/set-state-in-effect setSelected(stored); } } }, []); - // Sync with controlled value + // Sync with controlled value - controlled component pattern useEffect(() => { if (value) { + // eslint-disable-next-line react-hooks/set-state-in-effect setSelected(value); } }, [value]); @@ -94,10 +96,12 @@ export function getInstallCommand(packageName: string, manager: PackageManager): export function usePackageManager(): [PackageManager, (manager: PackageManager) => void] { const [manager, setManager] = useState('npm'); + // Load from localStorage on mount - intentional initial sync from browser storage useEffect(() => { if (typeof window !== 'undefined') { const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null; if (stored && packageManagers.some((pm) => pm.id === stored)) { + // eslint-disable-next-line react-hooks/set-state-in-effect setManager(stored); } } diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx index beb5d92..73b5027 100644 --- a/apps/web/src/components/ThemeToggle.tsx +++ b/apps/web/src/components/ThemeToggle.tsx @@ -9,8 +9,9 @@ export function ThemeToggle(): React.ReactElement { const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); - // useEffect only runs on the client, so we can safely show the UI + // useEffect only runs on the client - intentional hydration safety pattern useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); diff --git a/apps/web/src/components/dashboard/DashboardLayout.tsx b/apps/web/src/components/dashboard/DashboardLayout.tsx index a08e322..631a1f3 100644 --- a/apps/web/src/components/dashboard/DashboardLayout.tsx +++ b/apps/web/src/components/dashboard/DashboardLayout.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useSession } from '@/lib/auth-client'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon, type IconName } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import { useSession } from '@/lib/auth-client'; import { AppHeader } from '../AppHeader'; interface NavItem { @@ -70,9 +70,10 @@ export function DashboardLayout({ localStorage.setItem('dashboard-likes-expanded', String(likesExpanded)); }, [likesExpanded]); - // Auto-expand if on a likes page + // Auto-expand if on a likes page - intentional route-based UI sync useEffect(() => { if (pathname.startsWith('/dashboard/likes')) { + // eslint-disable-next-line react-hooks/set-state-in-effect setLikesExpanded(true); } }, [pathname]); @@ -84,9 +85,10 @@ export function DashboardLayout({ } }, [isPending, session, router]); - // Close sidebar on route change - pathname dependency triggers this effect + // Close sidebar on route change - intentional route-based UI sync // biome-ignore lint/correctness/useExhaustiveDependencies: pathname triggers effect useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setSidebarOpen(false); }, [pathname]); diff --git a/apps/web/src/components/home/CategoryGrid.tsx b/apps/web/src/components/home/CategoryGrid.tsx index d458084..df3a524 100644 --- a/apps/web/src/components/home/CategoryGrid.tsx +++ b/apps/web/src/components/home/CategoryGrid.tsx @@ -1,8 +1,8 @@ 'use client'; import { Card, CardContent } from '@tpmjs/ui/Card/Card'; -import { Icon } from '@tpmjs/ui/Icon/Icon'; import type { IconName } from '@tpmjs/ui/Icon/Icon'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; import { useScrollReveal } from '@tpmjs/ui/system/hooks/useScrollReveal'; export interface Category { @@ -32,7 +32,8 @@ export function CategoryGrid({ categories }: CategoryGridProps): React.ReactElem
{categories.map((category, index) => { const randomHeight = heights[index % heights.length] || 'h-36'; - const randomDelay = Math.random() * 400; // 0-400ms random delay + // Deterministic delay based on index for stable renders + const randomDelay = (index * 47) % 400; return ( {problemPoints.map((problem, index) => { - // Random rotation for chaos - const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 5 + 2); + // Deterministic rotation for chaos effect (stable across renders) + const rotation = (index % 2 === 0 ? 1 : -1) * (((index * 1.3) % 5) + 2); return (
return ( { - // Assign to both refs + // Assign to both refs - this is a standard ref callback pattern for merging refs if (scrollRef) { + // eslint-disable-next-line react-hooks/immutability (scrollRef as React.MutableRefObject).current = node; } if (typeof ref === 'function') { diff --git a/packages/ui/src/Checkbox/Checkbox.tsx b/packages/ui/src/Checkbox/Checkbox.tsx index 844512f..be77c89 100644 --- a/packages/ui/src/Checkbox/Checkbox.tsx +++ b/packages/ui/src/Checkbox/Checkbox.tsx @@ -1,5 +1,5 @@ import { cn } from '@tpmjs/utils/cn'; -import { forwardRef, useEffect, useRef } from 'react'; +import { forwardRef, useEffect, useId, useRef } from 'react'; import type { CheckboxProps } from './types'; import { checkboxLabelVariants, checkboxUIVariants, checkboxVariants } from './variants'; @@ -66,7 +66,8 @@ export const Checkbox = forwardRef( }, [indeterminate]); // Generate unique ID if not provided - const checkboxId = id || `checkbox-${Math.random().toString(36).substring(2, 9)}`; + const generatedId = useId(); + const checkboxId = id || `checkbox-${generatedId}`; const checkboxInput = ( ( return (
{ - // Assign to both refs + // Assign to both refs - this is a standard ref callback pattern for merging refs if (scrollRef) { + // eslint-disable-next-line react-hooks/immutability (scrollRef as React.MutableRefObject).current = node; } if (typeof ref === 'function') { diff --git a/packages/ui/src/Switch/Switch.tsx b/packages/ui/src/Switch/Switch.tsx index 7130ccd..16adc8b 100644 --- a/packages/ui/src/Switch/Switch.tsx +++ b/packages/ui/src/Switch/Switch.tsx @@ -1,5 +1,5 @@ import { cn } from '@tpmjs/utils/cn'; -import { forwardRef } from 'react'; +import { forwardRef, useId } from 'react'; import { useControlled } from '../system/useControlled'; import type { SwitchProps } from './types'; import { @@ -85,7 +85,8 @@ export const Switch = forwardRef( }; // Generate unique ID if not provided - const switchId = id || `switch-${Math.random().toString(36).substring(2, 9)}`; + const generatedId = useId(); + const switchId = id || `switch-${generatedId}`; // Determine data-state for styling const dataState = checked ? 'checked' : 'unchecked'; diff --git a/packages/ui/src/system/hooks/useParallax.ts b/packages/ui/src/system/hooks/useParallax.ts index 4ba8444..3dd8956 100644 --- a/packages/ui/src/system/hooks/useParallax.ts +++ b/packages/ui/src/system/hooks/useParallax.ts @@ -58,7 +58,8 @@ export function useParallax(options: UseParallaxOptions = {}): React.CSSProperti } }; - // Initial scroll position + // Initial scroll position - intentional initial sync from browser API + // eslint-disable-next-line react-hooks/set-state-in-effect setScrollY(window.scrollY); window.addEventListener('scroll', handleScroll, { passive: true }); diff --git a/packages/ui/src/system/hooks/useReducedMotion.ts b/packages/ui/src/system/hooks/useReducedMotion.ts index 0166477..5a7a1e9 100644 --- a/packages/ui/src/system/hooks/useReducedMotion.ts +++ b/packages/ui/src/system/hooks/useReducedMotion.ts @@ -25,7 +25,8 @@ export function useReducedMotion(): boolean { // Create media query const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - // Set initial value + // Set initial value - intentional initial sync from browser API + // eslint-disable-next-line react-hooks/set-state-in-effect setPrefersReducedMotion(mediaQuery.matches); // Update on change