Dev.to · 6 min read

The loading screen that took down every browser I opened

The loading screen that took down every browser I opened

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. There is a specific kind of bug that makes you question whether you know how to code at all. Not the kind where something throws a clear error and you trace it back in two minutes. The kind where the entire browser window starts flashing black and the only option is to kill the tab before Chrome locks up completely. That was the loading screen bug in my portfolio. The project I had just migrated my personal portfolio to Next.js 16 and was building a more complete version from scratch. The site had a custom loading screen mounted in the root layout. It needed to be there because it was the first thing users saw before any content loaded. The loader is not a simple spinner. It draws an SVG path that traces my initial letter G using a stroke animation, moves it to the left, reveals my full name alongside it, and then executes a color wipe exit animation in two stages before calling an onComplete callback and unmounting. There are refs for direct DOM manipulation of the SVG elements, state variables for the wipe stages, timers, and transition logic layered across all of it. const [wipe, setWipe] = useState(false); const [wipe2, setWipe2] = useState(false); const [hidden, setHidden] = useState(false); const [wipeColor] = useState( () => COLORS[Math.floor(Math.random() * COLORS.length)] ); const gPathRef = useRef(null); const gWrapperRef = useRef(null); const textGroupRef = useRef(null); const svgRef = useRef(null); const timerRef = useRef([]); Standard stuff. Or so I thought. What was happening The moment I opened the dev server, the browser window started flashing. Not a subtle flicker. The entire viewport alternating between black and content at full speed, over and over, with no way to stop it without closing the tab. Within seconds, Chrome would throw errors and warnings in the console and start slowing down noticeably. If I left it running, the browser would eventually saturate completely. The same thing happened in production. The page stayed black. The loop was executing so fast that the content never had time to render visibly. Anyone visiting the site would see a black screen and nothing else. I had to kill the local server every time I opened it just to be able to work on anything else in the project. Why it took time to find The symptom was so extreme that it pointed in the wrong direction. When a browser behaves like that, the instinct is to look for something major: a memory leak, a broken build configuration, a dependency conflict. I spent time checking all of those and found nothing. The component itself looked plausible when I read through it. There was state, there were refs, there was timer logic. No obvious loop anywhere in the code. What eventually led me to the actual cause was a combination of two things. I found posts on DEV describing similar symptoms, which pointed me toward infinite render loops as the likely category of problem. Then I opened React DevTools and looked at what was actually happening at runtime. The console was showing: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render. That error, combined with watching the component re-mount continuously in the DevTools component tree, made the cause clear. The problem was that state updates controlling the loader behavior were running during render, outside of any effect. In a component with this much timer and transition logic, that is easy to do accidentally. Every render triggered a state change, which triggered another render, which triggered another state change. The browser was re-rendering the entire root layout hundreds of times per second, which is why the viewport was flashing and everything eventually locked up. The complexity of the component made it harder to catch on a read-through because the timer logic created the impression that the state updates were conditional and time-bounded. They were not. The condition was being evaluated on every render. The fix The solution was two things applied together. First, separating the SVG animation logic and the wipe exit logic into two distinct useEffect hooks, each with explicit cleanup. The SVG animation runs once after mount. The wipe timers run in a separate effect that cleans up all three timeouts on unmount. // SVG draw and reveal, only needs to run once useEffect(() => { const gPath = gPathRef.current; const gWrapper = gWrapperRef.current; const textGroup = textGroupRef.current; const svg = svgRef.current; if (!gPath || !gWrapper || !textGroup || !svg) return; const length = gPath.getTotalLength(); gPath.style.strokeDasharray = `${length}`; gPath.style.strokeDashoffset = `${length}`; // force reflow so the transition actually fires gPath.getBoundingClientRect(); gPath.style.transition = "stroke-dashoffset 1.2s cubic-bezier(0.76, 0, 0.24, 1)"; gPath.style.strokeDashoffset = "0"; const t1 = setTimeout(() => { gWrapper.style.transition = "transform 0.7s cubic-bezier(0.16, 1, 0.3, 1)"; gWrapper.style.transform = "translateX(0px)"; setTimeout(() => { textGroup.style.transition = "opacity 0.5s ease"; textGroup.style.opacity = "1"; }, 350); }, 1300); return () => clearTimeout(t1); }, []); // Exit wipe timers, cleanup on unmount useEffect(() => { const t1 = setTimeout(() => setWipe(true), 2800); const t2 = setTimeout(() => setWipe2(true), 3200); const t3 = setTimeout(() => { setHidden(true); onComplete(); }, 3800); timerRef.current = [t1, t2, t3]; return () => timerRef.current.forEach(clearTimeout); }, [onComplete]); Second, I moved the loader into its own isolated component instead of keeping it inline in the root layout. That separation made the lifecycle predictable and prevented any accidental coupling with the state of other components in the layout tree. The wipeColor also uses a functional initializer in useState rather than computing a random value during render: const [wipeColor] = useState( () => COLORS[Math.floor(Math.random() * COLORS.length)] ); This matters in Next.js because components in the root layout render on the server first. A random value computed directly in the render body would produce a different result on the server versus the client, causing a hydration mismatch on top of the loop problem. What I took from this The Maximum update depth exceeded error is one of those React warnings that sounds abstract until you see what it actually produces at runtime. In this case: a completely unusable dev environment and a broken production deployment with a black screen. Reading through the code was not enough to catch it. The timer logic created a plausible narrative for why the state updates seemed controlled, and that made the actual problem invisible on a first read. What made the difference was switching from reading the code to watching the runtime behavior in DevTools, combined with finding documentation about similar symptoms. Infinite render loops in root layout components are particularly destructive because they take down the entire page, not just the component. A loop in a leaf component somewhere deep in the tree has an isolated impact. In the root layout, nothing works until the loop is gone. The loading screen now works exactly as intended. The G traces itself, slides into position, the name appears, the color wipe plays out in two stages, and the page loads cleanly underneath. No flashing, no black screen, no console warnings. Portfolio built with Next.js 16. Available at carlosjcastrog.com.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News