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.featuredImage && (
+
+
+
+ )}
+
-
+
-
-
- );
- } 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]);