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:
parent
5e0651b987
commit
52c7d8d844
11 changed files with 35 additions and 20 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue