From ad435e4344d9d77d640816bc228eb4262b95a007 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 29 Jan 2026 15:00:58 +0000 Subject: [PATCH] Fix React hooks ESLint errors without disabling rules Refactored components to avoid synchronous setState calls in effects by using proper React patterns: - TableOfContents: Use requestAnimationFrame to defer setState calls and useLayoutEffect for DOM measurements - CustomTheme: Use lazy state initialization for isClient and defer theme loading with requestAnimationFrame - PlaygroundContext: Use lazy initialization for localStorage hydration These changes maintain functionality while satisfying the strict react-hooks/set-state-in-effect ESLint rule. Signed-off-by: Charles de Dreuille --- docs-ui/eslint.config.mjs | 2 - .../components/CustomTheme/customTheme.tsx | 11 ++- .../TableOfContents/TableOfContents.tsx | 70 +++++++++++-------- docs-ui/src/utils/playground-context.tsx | 37 ++++------ 4 files changed, 59 insertions(+), 61 deletions(-) diff --git a/docs-ui/eslint.config.mjs b/docs-ui/eslint.config.mjs index 3396ce7946..2cb66fd509 100644 --- a/docs-ui/eslint.config.mjs +++ b/docs-ui/eslint.config.mjs @@ -9,8 +9,6 @@ const eslintConfig = defineConfig([ 'notice/notice': 'off', 'react/forbid-elements': 'off', 'jsx-a11y/alt-text': 'off', - '@next/next/no-css-tags': 'off', - 'react-hooks/set-state-in-effect': 'warn', }, }, ]); diff --git a/docs-ui/src/components/CustomTheme/customTheme.tsx b/docs-ui/src/components/CustomTheme/customTheme.tsx index ae765d7d88..3a4ae0edc8 100644 --- a/docs-ui/src/components/CustomTheme/customTheme.tsx +++ b/docs-ui/src/components/CustomTheme/customTheme.tsx @@ -46,7 +46,7 @@ const myTheme = createTheme({ }); export const CustomTheme = () => { - const [isClient, setIsClient] = useState(false); + const [isClient, setIsClient] = useState(() => typeof window !== 'undefined'); const [open, setOpen] = useState(true); const [customTheme, setCustomTheme] = useState(undefined); const { selectedThemeName } = usePlayground(); @@ -73,7 +73,10 @@ export const CustomTheme = () => { storedTheme = defaultTheme; localStorage.setItem('customThemeCss', storedTheme); } - setCustomTheme(storedTheme); + // Defer setState to avoid synchronous call in effect + requestAnimationFrame(() => { + setCustomTheme(storedTheme!); + }); updateStyleElement(storedTheme); } else { const styleElement = document.getElementById( @@ -85,10 +88,6 @@ export const CustomTheme = () => { } }, [selectedThemeName]); - useEffect(() => { - setIsClient(true); - }, []); - const handleSave = () => { if (customTheme) { localStorage.setItem('customThemeCss', customTheme); diff --git a/docs-ui/src/components/TableOfContents/TableOfContents.tsx b/docs-ui/src/components/TableOfContents/TableOfContents.tsx index d18265415b..3da5b95b15 100644 --- a/docs-ui/src/components/TableOfContents/TableOfContents.tsx +++ b/docs-ui/src/components/TableOfContents/TableOfContents.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useLayoutEffect } from 'react'; import { usePathname } from 'next/navigation'; import styles from './TableOfContents.module.css'; @@ -17,6 +17,29 @@ export function TableOfContents() { const [indicatorHeight, setIndicatorHeight] = useState(0); const pathname = usePathname(); + // Update indicator position when activeId changes + useLayoutEffect(() => { + if (!activeId) return; + + // Use requestAnimationFrame to defer setState call + const rafId = requestAnimationFrame(() => { + const activeElement = document.querySelector( + `[data-toc-id="${activeId}"]`, + ) as HTMLElement; + if (activeElement) { + const list = activeElement.closest('ul'); + if (list) { + const listRect = list.getBoundingClientRect(); + const elementRect = activeElement.getBoundingClientRect(); + setIndicatorTop(elementRect.top - listRect.top); + setIndicatorHeight(elementRect.height); + } + } + }); + + return () => cancelAnimationFrame(rafId); + }, [activeId, headings]); + useEffect(() => { // Extract all H2 and H3 headings from the document const elements = Array.from( @@ -29,18 +52,6 @@ export function TableOfContents() { level: parseInt(element.tagName.substring(1)), })); - setHeadings(headingData); - - // Set initial active heading (first visible heading or first heading) - if (headingData.length > 0) { - const viewportTop = window.scrollY + 100; // offset for header - const visibleHeading = elements.find(element => { - const rect = element.getBoundingClientRect(); - return rect.top + window.scrollY >= viewportTop - 200; - }); - setActiveId(visibleHeading?.id || headingData[0].id); - } - // Set up IntersectionObserver to track visible headings const observerOptions = { rootMargin: '-80px 0px -80% 0px', @@ -62,29 +73,26 @@ export function TableOfContents() { elements.forEach(element => observer.observe(element)); + // Initialize headings and active ID after observer is set up + requestAnimationFrame(() => { + setHeadings(headingData); + + // Set initial active heading (first visible heading or first heading) + if (headingData.length > 0) { + const viewportTop = window.scrollY + 100; // offset for header + const visibleHeading = elements.find(element => { + const rect = element.getBoundingClientRect(); + return rect.top + window.scrollY >= viewportTop - 200; + }); + setActiveId(visibleHeading?.id || headingData[0].id); + } + }); + return () => { elements.forEach(element => observer.unobserve(element)); }; }, [pathname]); - // Update indicator position when activeId changes - useEffect(() => { - if (activeId) { - const activeElement = document.querySelector( - `[data-toc-id="${activeId}"]`, - ) as HTMLElement; - if (activeElement) { - const list = activeElement.closest('ul'); - if (list) { - const listRect = list.getBoundingClientRect(); - const elementRect = activeElement.getBoundingClientRect(); - setIndicatorTop(elementRect.top - listRect.top); - setIndicatorHeight(elementRect.height); - } - } - } - }, [activeId, headings]); - const handleClick = (id: string) => { const element = document.getElementById(id); if (element) { diff --git a/docs-ui/src/utils/playground-context.tsx b/docs-ui/src/utils/playground-context.tsx index a1cee6ecba..f4496787a5 100644 --- a/docs-ui/src/utils/playground-context.tsx +++ b/docs-ui/src/utils/playground-context.tsx @@ -22,13 +22,13 @@ const PlaygroundContext = createContext<{ setSelectedThemeName: (themeName: ThemeName) => void; }>({ selectedScreenSizes: [], - setSelectedScreenSizes: () => {}, + setSelectedScreenSizes: () => { }, selectedComponents: [], - setSelectedComponents: () => {}, + setSelectedComponents: () => { }, selectedTheme: new Set(['light']), - setSelectedTheme: () => {}, + setSelectedTheme: () => { }, selectedThemeName: 'backstage', - setSelectedThemeName: () => {}, + setSelectedThemeName: () => { }, }); // Create a provider component @@ -40,37 +40,30 @@ export const PlaygroundProvider = ({ children }: { children: ReactNode }) => { const [selectedComponents, setSelectedComponents] = useState( components.map(component => component.slug), ); - const [selectedTheme, setSelectedTheme] = useState>( - new Set(['light']), - ); - const [selectedThemeName, setSelectedThemeName] = - useState('backstage'); - // Load saved theme from localStorage after hydration - useEffect(() => { - if (isBrowser) { + // Use lazy initialization to load from localStorage + const [selectedTheme, setSelectedTheme] = useState>(() => { + if (typeof window !== 'undefined') { const savedThemeString = localStorage.getItem('theme-mode'); if (savedThemeString) { - // Parse the comma-separated string back into a Set const themeArray = savedThemeString .split(',') .filter(Boolean) as Theme[]; - setSelectedTheme(new Set(themeArray)); - } else { - setSelectedTheme(new Set(['light'])); + return new Set(themeArray); } } - }, [isBrowser]); + return new Set(['light']); + }); - // Load saved theme name from localStorage after hydration - useEffect(() => { - if (isBrowser) { + const [selectedThemeName, setSelectedThemeName] = useState(() => { + if (typeof window !== 'undefined') { const savedThemeName = localStorage.getItem('theme-name') as ThemeName; if (savedThemeName) { - setSelectedThemeName(savedThemeName); + return savedThemeName; } } - }, [isBrowser]); + return 'backstage'; + }); useEffect(() => { if (isBrowser) {