fix: prevent infinite re-render loop in settings sections

- Memoize context value in SettingsSectionsProvider
- Use ctxRef pattern in hook to avoid depending on ctx object
  in useLayoutEffect deps (ctx object changes on every render,
  causing effect → setState → re-render → effect loop)
This commit is contained in:
Jannis Braun
2026-03-22 03:32:43 +01:00
parent 9210fb6616
commit fbf83cf8f4
2 changed files with 30 additions and 31 deletions
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from 'react';
export interface SettingsSection { export interface SettingsSection {
id: string; id: string;
@@ -33,18 +33,18 @@ export function SettingsSectionsProvider({ children }: { children: React.ReactNo
setScrollFn(() => fn); setScrollFn(() => fn);
}, []); }, []);
const value = useMemo(() => ({
sections,
activeSection,
scrollToSection,
scrollContainerRef,
setSections,
setActiveSection,
setScrollToSection: setScrollToSectionStable,
}), [sections, activeSection, scrollToSection, scrollContainerRef, setScrollToSectionStable]);
return ( return (
<SettingsSectionsContext.Provider <SettingsSectionsContext.Provider value={value}>
value={{
sections,
activeSection,
scrollToSection,
scrollContainerRef,
setSections,
setActiveSection,
setScrollToSection: setScrollToSectionStable,
}}
>
{children} {children}
</SettingsSectionsContext.Provider> </SettingsSectionsContext.Provider>
); );
+18 -19
View File
@@ -3,8 +3,13 @@ import { useSettingsSectionsContext, type SettingsSection } from '../components/
export function useSettingsSections(sections: SettingsSection[]) { export function useSettingsSections(sections: SettingsSection[]) {
const ctx = useSettingsSectionsContext(); const ctx = useSettingsSectionsContext();
// scrollContainerRef is owned by the context provider, not by this hook. // Store ctx setters in refs to avoid depending on the ctx object in effects.
// UserSettings attaches it to the scroll container div, and this hook reads it. // The ctx object reference changes when any context value changes, which would
// cause infinite loops if used as an effect dependency (effect sets state →
// provider re-renders → new ctx object → effect re-runs).
const ctxRef = useRef(ctx);
ctxRef.current = ctx;
const scrollContainerRef = ctx?.scrollContainerRef ?? null; const scrollContainerRef = ctx?.scrollContainerRef ?? null;
const sectionElementsRef = useRef(new Map<string, HTMLElement>()); const sectionElementsRef = useRef(new Map<string, HTMLElement>());
const sectionRefCallbacksRef = useRef(new Map<string, (el: HTMLElement | null) => void>()); const sectionRefCallbacksRef = useRef(new Map<string, (el: HTMLElement | null) => void>());
@@ -13,16 +18,12 @@ export function useSettingsSections(sections: SettingsSection[]) {
// Register sections into context synchronously (useLayoutEffect prevents flicker) // Register sections into context synchronously (useLayoutEffect prevents flicker)
useLayoutEffect(() => { useLayoutEffect(() => {
if (ctx) { ctxRef.current?.setSections(sections);
ctx.setSections(sections);
}
return () => { return () => {
if (ctx) { ctxRef.current?.setSections([]);
ctx.setSections([]); ctxRef.current?.setActiveSection('');
ctx.setActiveSection('');
}
}; };
}, [sections, ctx]); }, [sections]);
// scrollToSection implementation // scrollToSection implementation
const scrollToSection = useCallback((id: string) => { const scrollToSection = useCallback((id: string) => {
@@ -32,7 +33,7 @@ export function useSettingsSections(sections: SettingsSection[]) {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
suppressObserverRef.current = true; suppressObserverRef.current = true;
ctx?.setActiveSection(id); ctxRef.current?.setActiveSection(id);
el.scrollIntoView({ el.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth', behavior: prefersReducedMotion ? 'auto' : 'smooth',
@@ -49,14 +50,12 @@ export function useSettingsSections(sections: SettingsSection[]) {
suppressObserverRef.current = false; suppressObserverRef.current = false;
}, prefersReducedMotion ? 50 : 500); }, prefersReducedMotion ? 50 : 500);
} }
}, [ctx, scrollContainerRef]); }, [scrollContainerRef]);
// Register scrollToSection into context // Register scrollToSection into context
useLayoutEffect(() => { useLayoutEffect(() => {
if (ctx) { ctxRef.current?.setScrollToSection(scrollToSection);
ctx.setScrollToSection(scrollToSection); }, [scrollToSection]);
}
}, [scrollToSection, ctx]);
// Set up IntersectionObserver // Set up IntersectionObserver
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -69,8 +68,8 @@ export function useSettingsSections(sections: SettingsSection[]) {
for (const entry of entries) { for (const entry of entries) {
if (entry.isIntersecting) { if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-section-id'); const id = entry.target.getAttribute('data-section-id');
if (id && ctx) { if (id) {
ctx.setActiveSection(id); ctxRef.current?.setActiveSection(id);
} }
} }
} }
@@ -90,7 +89,7 @@ export function useSettingsSections(sections: SettingsSection[]) {
observerRef.current?.disconnect(); observerRef.current?.disconnect();
observerRef.current = null; observerRef.current = null;
}; };
}, [sections, ctx, scrollContainerRef]); }, [sections, scrollContainerRef]);
// Stable callback ref factory (cached per id to avoid re-attach) // Stable callback ref factory (cached per id to avoid re-attach)
const sectionRef = useCallback((id: string) => { const sectionRef = useCallback((id: string) => {