Recently, I started building DevGraph, a developer tool designed to help engineers understand entire codebases instead of just reading individual files.
The vision is simple:
Developers can read code. Developers struggle to understand systems.
DevGraph aims to visualize architecture, execution flows, dependencies, and learning paths inside large repositories.
As I was building the frontend, I encountered one of the most frustrating bugs I've seen during development.
A completely black screen.
The Symptom
The application would:
- Start normally.
- Display the UI for about one second.
- Instantly disappear.
- Leave only a black screen.
At first glance, this looked like a CSS issue.
The obvious suspects were:
display: nonevisibility: hiddenopacity: 0- Full-screen overlays
- Incorrect z-index
- Tailwind utility conflicts
- Broken layout containers
The browser network panel looked healthy. The assets loaded. The Vite development server worked. The JavaScript bundles were delivered correctly.
Everything suggested the UI should be visible.
Yet it wasn't.
My First Assumption Was Wrong
Like many frontend developers, I immediately blamed CSS.
The screen was black. The UI vanished. It had to be styling.
So I began investigating:
rg -n "opacity-0|hidden|invisible|display:none|visibility:hidden" src
Nothing.
I checked global styles, layout containers, root element sizing, positioning, and overflow rules.
Still nothing. The bug remained.
Isolating the Problem
Instead of guessing, I reduced the application to the smallest possible test.
I replaced the entire application with:
function App() {
return (
<div
style={{
minHeight: "100vh",
background: "black",
color: "white",
padding: "40px",
}}
>
DEVGRAPH IS RENDERING
</div>
);
}
The result? The text stayed visible forever. No disappearing. No black screen. No crash.
That immediately proved something important:
The rendering system works.
The root mount works.
The CSS isn't the primary problem.
The issue existed somewhere inside the component tree.
Looking at the Console
Once I focused on runtime behavior instead of styling, the browser console exposed the real issue.
React reported:
The result of getSnapshot should be cached
to avoid an infinite loop
Followed by:
Maximum update depth exceeded
And finally:
An error occurred in the <RepositoryHeader> component
Now the mystery was starting to make sense.
The UI wasn't disappearing. React was crashing.
The Real Root Cause
The issue originated from Zustand store selectors.
I had code similar to:
const { repository, selectedFile } =
useRepositoryStore((state) => ({
repository: state.repository,
selectedFile: state.selectedFile,
}));
At first glance, this looks harmless.
However, there is a subtle problem. Every render creates a brand-new object:
{
repository: state.repository,
selectedFile: state.selectedFile
}
Even if the values are identical, the object reference changes.
React 19 and Zustand 5 are much stricter about external store subscriptions. Internally, React compares snapshots returned by the store. Because a new object was being returned every render:
Render
↓
New Object
↓
Snapshot Changed
↓
Re-render
↓
New Object
↓
Snapshot Changed
↓
Re-render
The cycle never ended. React entered an infinite update loop. Eventually it threw Maximum update depth exceeded and the entire component tree collapsed.
The Fix
The solution was to use shallow comparison.
Instead of:
useRepositoryStore((state) => ({
repository: state.repository,
selectedFile: state.selectedFile,
}));
I changed it to:
import { useShallow } from "zustand/react/shallow";
const { repository, selectedFile } =
useRepositoryStore(
useShallow((state) => ({
repository: state.repository,
selectedFile: state.selectedFile,
}))
);
Now Zustand only triggers re-renders when the selected values actually change. No unnecessary object reference changes. No infinite loop. No React crash.
The Result
After applying shallow selectors across the repository components — RepositoryHeader, RepositoryTabs, RepositoryTree, CodeViewer, FlowPreview, and InsightPanel — the application became stable.
The UI no longer disappeared. The development server worked normally. The production build succeeded. The DevGraph workspace rendered correctly.
Lessons Learned
This bug reinforced several engineering principles.
1. Don't Trust First Impressions
A black screen looked like CSS. It wasn't. The real issue was state management.
2. Reduce Before You Debug
Replacing the entire app with a simple component immediately narrowed the search space. Small experiments beat random guessing.
3. Read Console Errors Carefully
The browser told me exactly what was wrong — getSnapshot and Maximum update depth exceeded. I simply wasn't looking in the right place initially.
4. Zustand Selectors Matter
Returning new object literals from selectors can create subtle rendering problems. With modern React and Zustand versions, proper selector optimization becomes increasingly important.
Final Thoughts
This wasn't a difficult bug because of its complexity.
It was difficult because the symptom and the cause lived in completely different layers of the application. The screen looked broken. The CSS looked suspicious. The actual problem was an infinite render loop caused by store subscriptions.
As DevGraph grows, I expect to encounter many more problems like this. And that's exactly why I enjoy building developer tools.
Every bug is another opportunity to understand the system more deeply.
Sometimes the fastest way to solve a problem is not writing more code. It's understanding what the system is trying to tell you.