diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx index c28781c1..f64c2add 100644 --- a/app/auth/forgot-password/page.tsx +++ b/app/auth/forgot-password/page.tsx @@ -32,9 +32,15 @@ export default function ForgotPasswordPage() { return () => clearInterval(timer); }, [rateLimitSeconds]); - // Redirect if already logged in + // Redirect if already logged in. Runs in an effect (not during render) so the + // navigation side effect stays out of the render phase. + useEffect(() => { + if (user) { + window.location.href = ROUTES.SETTINGS; + } + }, [user]); + if (user) { - window.location.href = ROUTES.SETTINGS; return null; } diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx index 91e66317..1a29119d 100644 --- a/app/auth/reset-password/page.tsx +++ b/app/auth/reset-password/page.tsx @@ -23,6 +23,7 @@ export default function ResetPasswordPage() { // User should have a session from the email link // Supabase handles the token exchange automatically if (session) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs a local validity flag to the external Supabase auth session setIsValidSession(true); } else { // Give it a moment for the session to be established diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 3f8018e5..36cc46ea 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -33,9 +33,15 @@ export default function SignInPage() { return () => clearInterval(timer); }, [rateLimitSeconds]); - // Redirect if already logged in + // Redirect if already logged in. Runs in an effect (not during render) so the + // navigation side effect stays out of the render phase. + useEffect(() => { + if (user) { + window.location.href = ROUTES.SETTINGS; + } + }, [user]); + if (user) { - window.location.href = ROUTES.SETTINGS; return null; } diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index ab7319f7..078f1d7e 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -34,9 +34,15 @@ export default function SignUpPage() { return () => clearInterval(timer); }, [rateLimitSeconds]); - // Redirect if already logged in + // Redirect if already logged in. Runs in an effect (not during render) so the + // navigation side effect stays out of the render phase. + useEffect(() => { + if (user) { + window.location.href = ROUTES.SETTINGS; + } + }, [user]); + if (user) { - window.location.href = ROUTES.SETTINGS; return null; } diff --git a/app/auth/verify-email/page.tsx b/app/auth/verify-email/page.tsx index 206e7852..5a0b9b54 100644 --- a/app/auth/verify-email/page.tsx +++ b/app/auth/verify-email/page.tsx @@ -36,6 +36,20 @@ export default function VerifyEmailPage() { return () => clearInterval(timer); }, [rateLimitSeconds]); + // Redirects run in effects (not during render) so the navigation side effects + // stay out of the render phase. + useEffect(() => { + if (!loading && !user) { + window.location.href = ROUTES.AUTH.SIGNIN; + } + }, [loading, user]); + + useEffect(() => { + if (!loading && user && isEmailVerified) { + window.location.href = ROUTES.SETTINGS; + } + }, [loading, user, isEmailVerified]); + const isRateLimited = rateLimitSeconds > 0; const handleResend = async () => { @@ -72,15 +86,13 @@ export default function VerifyEmailPage() { return ; } - // Not logged in - redirect to signin + // Not logged in - the effect above redirects to signin. if (!user) { - window.location.href = ROUTES.AUTH.SIGNIN; return null; } - // Already verified - redirect to settings + // Already verified - the effect above redirects to settings. if (isEmailVerified) { - window.location.href = ROUTES.SETTINGS; return null; } diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 48c27d8e..1800aaf2 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -74,64 +74,62 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str const { slug } = await params; console.info('Rendering blog post for slug:', slug); - try { - if (!slug) { - console.info('Missing slug parameter'); - notFound(); - } + // No try/catch: notFound() and render errors must propagate to Next's + // not-found.tsx / error.tsx boundaries. A catch that only re-throws would also + // spuriously log notFound() as an error. + if (!slug) { + console.info('Missing slug parameter'); + notFound(); + } - const post = await fetchBlogPostBySlug(slug); + const post = await fetchBlogPostBySlug(slug); - if (!post) { - console.info('Blog post not found for slug:', slug); - notFound(); - } + if (!post) { + console.info('Blog post not found for slug:', slug); + notFound(); + } - return ( -
-
-
- - - {post.author} -
+ return ( +
+
+
+ + + {post.author} +
-

{post.title}

+

{post.title}

- {post.tags && post.tags.length > 0 && ( -
- {post.tags.map((tag) => ( - - {tag} - - ))} -
- )} + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map((tag) => ( + + {tag} + + ))} +
+ )} - {post.featuredImage && ( -
- {post.title} -
- )} -
+ {post.featuredImage && ( +
+ {post.title} +
+ )} +
- + - -
- ); - } catch (error) { - console.info('Error rendering blog post:', error); - throw error; - } + + + ); } diff --git a/app/projects/governance/components/CitizenProfile.tsx b/app/projects/governance/components/CitizenProfile.tsx index 3e337d59..e010bcbc 100644 --- a/app/projects/governance/components/CitizenProfile.tsx +++ b/app/projects/governance/components/CitizenProfile.tsx @@ -46,6 +46,7 @@ const CitizenProfile: React.FC = ({ citizen, agencies: _age advisoryPercentage: contribution.percentage, difference: 0, })); + // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds editable advisory distribution from the citizen prop setAdvisoryDistribution(initialDistribution); } }, [citizen.contributions]); diff --git a/app/projects/governance/components/citizen-profile/index.tsx b/app/projects/governance/components/citizen-profile/index.tsx index 0807e220..5055dbd1 100644 --- a/app/projects/governance/components/citizen-profile/index.tsx +++ b/app/projects/governance/components/citizen-profile/index.tsx @@ -36,6 +36,7 @@ const CitizenProfile: React.FC = ({ citizen, agencies: _age advisoryPercentage: contribution.percentage, difference: 0, })); + // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds editable advisory distribution from the citizen prop setAdvisoryDistribution(initialDistribution); } }, [citizen.contributions]); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 616f888e..1f72bbbc 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -44,6 +44,7 @@ export default function SettingsPage() { // Initialize profile form when user data loads useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds editable form fields from async-loaded user metadata if (displayName) setProfileDisplayName(displayName); if (avatarUrl) setProfileAvatarUrl(avatarUrl); }, [displayName, avatarUrl]); diff --git a/components/blog/Comments.tsx b/components/blog/Comments.tsx index 06b9ea34..e3925974 100644 --- a/components/blog/Comments.tsx +++ b/components/blog/Comments.tsx @@ -7,6 +7,7 @@ export default function Comments({ slug }: { slug: string }) { const [isClient, setIsClient] = useState(false); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- client-mount detection for SSR-safe giscus embed setIsClient(true); }, []); diff --git a/components/blog/MDXComponents.tsx b/components/blog/MDXComponents.tsx index 0ee4b454..a409ed38 100644 --- a/components/blog/MDXComponents.tsx +++ b/components/blog/MDXComponents.tsx @@ -70,6 +70,7 @@ const Img = ( // passes string paths, so narrow before the string operations below. if (!src || typeof src !== 'string') { console.info('Image source missing'); + // eslint-disable-next-line react-hooks/set-state-in-effect -- client-only image URL resolution (reads window.location as a slug fallback) setIsError(true); return; } diff --git a/components/conversations/ConversationList.tsx b/components/conversations/ConversationList.tsx index f85f9d8f..c581bc19 100644 --- a/components/conversations/ConversationList.tsx +++ b/components/conversations/ConversationList.tsx @@ -49,6 +49,7 @@ export const ConversationList = ({ }, [botType, botId, documentId]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- loads conversations from the API on mount loadConversations(); }, [loadConversations, refreshTrigger]); diff --git a/components/shared/ProfessionalDemo.tsx b/components/shared/ProfessionalDemo.tsx index ea07b52d..1d019d34 100644 --- a/components/shared/ProfessionalDemo.tsx +++ b/components/shared/ProfessionalDemo.tsx @@ -51,6 +51,7 @@ export const ProfessionalDemo: FC = ({ professional }) => role: 'assistant', content: getWelcomeMessage(), }; + // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds the on-mount welcome message setMessages([welcomeMessage]); inputRef.current?.focus({ preventScroll: true }); }, [getWelcomeMessage]); diff --git a/components/shared/quick-create/QuickChat.tsx b/components/shared/quick-create/QuickChat.tsx index a7a8c4e5..6dd8f5c5 100644 --- a/components/shared/quick-create/QuickChat.tsx +++ b/components/shared/quick-create/QuickChat.tsx @@ -80,6 +80,7 @@ export const QuickChat: FC = ({ bot, onReset }) => { role: 'assistant', content: getWelcomeMessage(), }; + // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds the on-mount welcome message setMessages([welcomeMessage]); inputRef.current?.focus({ preventScroll: true }); }, [getWelcomeMessage]); diff --git a/eslint.config.mjs b/eslint.config.mjs index a7af9ee7..f423fc61 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,15 +45,6 @@ const eslintConfig = [ 'react/no-unescaped-entities': 'off', 'react/display-name': 'off', '@next/next/no-img-element': 'warn', - // eslint-plugin-react-hooks v6 (bundled with Next 16's eslint-config-next) - // adds React-Compiler-era rules that did not exist in the Next 15 config and - // flag pre-existing intentional patterns (localStorage hydration, load-on-mount - // effects). Adopting them is a dedicated refactor, out of scope for this - // framework upgrade — deferred, tracked as follow-up. rules-of-hooks and - // exhaustive-deps stay enabled. - 'react-hooks/set-state-in-effect': 'off', - 'react-hooks/error-boundaries': 'off', - 'react-hooks/immutability': 'off', }, }, ]; diff --git a/hooks/useDocumentChat.ts b/hooks/useDocumentChat.ts index f6a9fc5d..3fbce133 100644 --- a/hooks/useDocumentChat.ts +++ b/hooks/useDocumentChat.ts @@ -29,6 +29,7 @@ export const useDocumentChat = ({ selectedDocId }: UseDocumentChatOptions) => { // Load conversation messages when active conversation changes useEffect(() => { if (!activeConversationId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- resets messages when the active conversation clears setChatMessages([]); return; } diff --git a/lib/auth.tsx b/lib/auth.tsx index cd1dd33f..32684fe8 100644 --- a/lib/auth.tsx +++ b/lib/auth.tsx @@ -76,6 +76,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // If Supabase is not configured, just set loading to false if (!client) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initializes auth state from the external Supabase client setLoading(false); return; } diff --git a/lib/hooks/useDashboardStats.ts b/lib/hooks/useDashboardStats.ts index cf82a024..86a25e68 100644 --- a/lib/hooks/useDashboardStats.ts +++ b/lib/hooks/useDashboardStats.ts @@ -116,6 +116,7 @@ export function useDashboardStats(userId: string | undefined): UseDashboardStats }, [userId]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- loads dashboard stats from the API on mount loadData(); }, [loadData]); diff --git a/lib/hooks/useLocalStorage.ts b/lib/hooks/useLocalStorage.ts index 206cb256..1c6a9db5 100644 --- a/lib/hooks/useLocalStorage.ts +++ b/lib/hooks/useLocalStorage.ts @@ -15,6 +15,7 @@ export function useLocalStorageFlag( useEffect(() => { const stored = localStorage.getItem(key); if (stored !== null) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrates from localStorage after mount (SSR-safe by design) setValue(stored === 'true'); } }, [key]);