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:
@@ -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,9 +33,7 @@ export function SettingsSectionsProvider({ children }: { children: React.ReactNo
|
|||||||
setScrollFn(() => fn);
|
setScrollFn(() => fn);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const value = useMemo(() => ({
|
||||||
<SettingsSectionsContext.Provider
|
|
||||||
value={{
|
|
||||||
sections,
|
sections,
|
||||||
activeSection,
|
activeSection,
|
||||||
scrollToSection,
|
scrollToSection,
|
||||||
@@ -43,8 +41,10 @@ export function SettingsSectionsProvider({ children }: { children: React.ReactNo
|
|||||||
setSections,
|
setSections,
|
||||||
setActiveSection,
|
setActiveSection,
|
||||||
setScrollToSection: setScrollToSectionStable,
|
setScrollToSection: setScrollToSectionStable,
|
||||||
}}
|
}), [sections, activeSection, scrollToSection, scrollContainerRef, setScrollToSectionStable]);
|
||||||
>
|
|
||||||
|
return (
|
||||||
|
<SettingsSectionsContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</SettingsSectionsContext.Provider>
|
</SettingsSectionsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user