fix: resolve new ESLint rules from eslint-plugin-react-hooks update

- 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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-09 23:25:15 +10:00
parent 5e0651b987
commit 52c7d8d844
11 changed files with 35 additions and 20 deletions

View file

@ -26,19 +26,21 @@ export function PackageManagerSelector({
}: PackageManagerSelectorProps): React.ReactElement { }: PackageManagerSelectorProps): React.ReactElement {
const [selected, setSelected] = useState<PackageManager>('npm'); const [selected, setSelected] = useState<PackageManager>('npm');
// Load from localStorage on mount // Load from localStorage on mount - intentional initial sync from browser storage
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null; const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null;
if (stored && packageManagers.some((pm) => pm.id === stored)) { if (stored && packageManagers.some((pm) => pm.id === stored)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelected(stored); setSelected(stored);
} }
} }
}, []); }, []);
// Sync with controlled value // Sync with controlled value - controlled component pattern
useEffect(() => { useEffect(() => {
if (value) { if (value) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelected(value); setSelected(value);
} }
}, [value]); }, [value]);
@ -94,10 +96,12 @@ export function getInstallCommand(packageName: string, manager: PackageManager):
export function usePackageManager(): [PackageManager, (manager: PackageManager) => void] { export function usePackageManager(): [PackageManager, (manager: PackageManager) => void] {
const [manager, setManager] = useState<PackageManager>('npm'); const [manager, setManager] = useState<PackageManager>('npm');
// Load from localStorage on mount - intentional initial sync from browser storage
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null; const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null;
if (stored && packageManagers.some((pm) => pm.id === stored)) { if (stored && packageManagers.some((pm) => pm.id === stored)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setManager(stored); setManager(stored);
} }
} }

View file

@ -9,8 +9,9 @@ export function ThemeToggle(): React.ReactElement {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false); 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(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true); setMounted(true);
}, []); }, []);

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { useSession } from '@/lib/auth-client';
import { Button } from '@tpmjs/ui/Button/Button'; import { Button } from '@tpmjs/ui/Button/Button';
import { Icon, type IconName } from '@tpmjs/ui/Icon/Icon'; import { Icon, type IconName } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSession } from '@/lib/auth-client';
import { AppHeader } from '../AppHeader'; import { AppHeader } from '../AppHeader';
interface NavItem { interface NavItem {
@ -70,9 +70,10 @@ export function DashboardLayout({
localStorage.setItem('dashboard-likes-expanded', String(likesExpanded)); localStorage.setItem('dashboard-likes-expanded', String(likesExpanded));
}, [likesExpanded]); }, [likesExpanded]);
// Auto-expand if on a likes page // Auto-expand if on a likes page - intentional route-based UI sync
useEffect(() => { useEffect(() => {
if (pathname.startsWith('/dashboard/likes')) { if (pathname.startsWith('/dashboard/likes')) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLikesExpanded(true); setLikesExpanded(true);
} }
}, [pathname]); }, [pathname]);
@ -84,9 +85,10 @@ export function DashboardLayout({
} }
}, [isPending, session, router]); }, [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 // biome-ignore lint/correctness/useExhaustiveDependencies: pathname triggers effect
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSidebarOpen(false); setSidebarOpen(false);
}, [pathname]); }, [pathname]);

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import { Card, CardContent } from '@tpmjs/ui/Card/Card'; import { Card, CardContent } from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { IconName } 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'; import { useScrollReveal } from '@tpmjs/ui/system/hooks/useScrollReveal';
export interface Category { export interface Category {
@ -32,7 +32,8 @@ export function CategoryGrid({ categories }: CategoryGridProps): React.ReactElem
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{categories.map((category, index) => { {categories.map((category, index) => {
const randomHeight = heights[index % heights.length] || 'h-36'; 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 ( return (
<CategoryTile <CategoryTile
@ -62,8 +63,9 @@ function CategoryTile({
delay, delay,
}); });
// Random rotation for entrance animation // Deterministic rotation based on delay for stable entrance animation
const randomRotation = (Math.random() - 0.5) * 20; // -10 to +10 degrees // Creates -10 to +10 degrees based on delay value
const randomRotation = (delay % 20) - 10;
return ( return (
<a <a

View file

@ -24,8 +24,8 @@ export function ProblemSection(): React.ReactElement {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{problemPoints.map((problem, index) => { {problemPoints.map((problem, index) => {
// Random rotation for chaos // Deterministic rotation for chaos effect (stable across renders)
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 5 + 2); const rotation = (index % 2 === 0 ? 1 : -1) * (((index * 1.3) % 5) + 2);
return ( return (
<div <div

View file

@ -82,8 +82,9 @@ export const AnimatedCounter = forwardRef<HTMLSpanElement, AnimatedCounterProps>
return ( return (
<span <span
ref={(node) => { ref={(node) => {
// Assign to both refs // Assign to both refs - this is a standard ref callback pattern for merging refs
if (scrollRef) { if (scrollRef) {
// eslint-disable-next-line react-hooks/immutability
(scrollRef as React.MutableRefObject<HTMLSpanElement | null>).current = node; (scrollRef as React.MutableRefObject<HTMLSpanElement | null>).current = node;
} }
if (typeof ref === 'function') { if (typeof ref === 'function') {

View file

@ -1,5 +1,5 @@
import { cn } from '@tpmjs/utils/cn'; 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 type { CheckboxProps } from './types';
import { checkboxLabelVariants, checkboxUIVariants, checkboxVariants } from './variants'; import { checkboxLabelVariants, checkboxUIVariants, checkboxVariants } from './variants';
@ -66,7 +66,8 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
}, [indeterminate]); }, [indeterminate]);
// Generate unique ID if not provided // 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 = ( const checkboxInput = (
<input <input

View file

@ -70,8 +70,9 @@ export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
return ( return (
<div <div
ref={(node) => { ref={(node) => {
// Assign to both refs // Assign to both refs - this is a standard ref callback pattern for merging refs
if (scrollRef) { if (scrollRef) {
// eslint-disable-next-line react-hooks/immutability
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = node; (scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
} }
if (typeof ref === 'function') { if (typeof ref === 'function') {

View file

@ -1,5 +1,5 @@
import { cn } from '@tpmjs/utils/cn'; import { cn } from '@tpmjs/utils/cn';
import { forwardRef } from 'react'; import { forwardRef, useId } from 'react';
import { useControlled } from '../system/useControlled'; import { useControlled } from '../system/useControlled';
import type { SwitchProps } from './types'; import type { SwitchProps } from './types';
import { import {
@ -85,7 +85,8 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
}; };
// Generate unique ID if not provided // 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 // Determine data-state for styling
const dataState = checked ? 'checked' : 'unchecked'; const dataState = checked ? 'checked' : 'unchecked';

View file

@ -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); setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true });

View file

@ -25,7 +25,8 @@ export function useReducedMotion(): boolean {
// Create media query // Create media query
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); 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); setPrefersReducedMotion(mediaQuery.matches);
// Update on change // Update on change