We built neorgon.com to showcase 18 open-source tools we've built over the past few years. The hub is deceptively simple: a searchable grid of cards, each linking to a tool. Under the hood, we've added physics simulations, draggable reordering, and animated search results. It's all vanilla JavaScript—no React, no Vue, no framework. Just plain old DOM manipulation.
And that's where things went wrong.
The Bug Report
"The search bar is failing. Originally it was working, but the current fix damaged the result filtering and when you delete or cancel the search no cards are displayed."
Classic bug report: it was working, then it broke. The user types something in, gets filtered results, clears the search, and... nothing. A blank page where 18 tools used to be.
The Setup: How Search Works
Before we dive into the bug, let's understand the architecture. Our hub is split into "groups"—Planning, DevOps, Data, Productivity, etc. Each group has a label and a .sites-grid container with cards. We use CSS Grid for layout:
<div class="card-group" data-group="planning">
<h3 class="group-label">Planning</h3>
<div class="sites-grid">
<a class="site-card" data-card-id="pathfinder">...</a>
<a class="site-card" data-card-id="skillmap">...</a>
</div>
</div>
The search functionality did two things:
- Filtering: When you type, cards that don't match get a
.search-hiddenclass (display: none !important). - Consolidation: Matching cards get moved from their groups into a special "search results grid" at the top of the page.
Why move cards? We wanted a clean, unified results view. No group labels, no empty sections—just the matching cards in one grid.
When you clear the search, the code should:
- Move all cards back to their original groups
- Remove the
.search-hiddenclass - Remove group indicators
- Hide the search results grid
Simple, right? Wrong.
The Problem: Race Conditions and Stale State
Here was the original code (simplified):
function doFilter() {
// Rebuild card collection and index
var currentCards = Array.from(document.querySelectorAll('.site-card'));
if (!query) {
// Clearing search - move cards back
var searchGrid = document.getElementById('searchResultsGrid');
if (searchGrid && searchGrid.children.length > 0) {
var cardsInGrid = Array.from(searchGrid.children);
cardsInGrid.forEach(function (c) {
var group = findOriginalGroup(c.dataset.cardId);
if (group) group.appendChild(c); // BUG #1
});
}
// Then make all cards visible
currentCards.forEach(function (c) {
c.classList.remove('search-hidden');
});
return;
}
// ... filtering logic
}
The Three Fatal Bugs
Bug #1: DOM References vs. Live Collections
Here's the first problem: currentCards is created at the start of the function. It captures all cards that exist in the DOM right now—including cards that are already in the search grid.
Then, we move cards from the search grid back to their groups. But currentCards still has references to those cards in their old positions.
When we try to make all cards visible by iterating through currentCards, we're operating on stale references. If a card has moved, we might be manipulating a node that's no longer where we think it is—or worse, manipulating the same card twice.
Bug #2: Appending to the Wrong Container
Look at line 11: group.appendChild(c). What's group? It's the group container:
<div class="card-group" data-group="planning">
<h3 class="group-label">...</h3>
<div class="sites-grid"> // We want to append here!
<!-- cards -->
</div>
</div>
We were appending cards directly to the .card-group div, not the .sites-grid container. This broke the layout completely. Cards appeared outside the grid, CSS selectors failed, and everything looked broken.
Bug #3: The Missing Moving Cards
The third bug was the most subtle. When you searched and got results, cards moved into the search grid. When you cleared the search, we moved them back. But here's the sequence:
- User types "planning" → 2 cards match → they move to search grid
- User clears search → we iterate
currentCards→ butcurrentCardswas built from cards before we cleared the search grid - The cards that were in the search grid are still in the grid, but now we reference them as if they're in their original groups
Result: some cards ended up in limbo—copied into the search grid during filtering, but never moved back during cleanup.
The Root Cause
The real problem wasn't the code—it was when we ran the code. We were rebuilding the DOM while trying to filter based on the old DOM state.
The Fix: Clear, Build, Filter
The solution was to enforce a strict order:
function doFilter() {
var q = input.value.trim().toLowerCase();
// 1. BEFORE ANYTHING: Clean up from previous searches
var searchGrid = document.getElementById('searchResultsGrid');
if (searchGrid && searchGrid.children.length > 0) {
var cardsInGrid = Array.from(searchGrid.children);
cardsInGrid.forEach(function (c) {
var group = findOriginalGroup(c.dataset.cardId);
if (group) {
var sitesGrid = group.querySelector('.sites-grid');
if (sitesGrid) sitesGrid.appendChild(c); // Correct container!
else group.appendChild(c); // Fallback
}
c.classList.remove('search-hidden');
var indicator = c.querySelector('.card-group-indicator');
if (indicator) indicator.remove();
});
searchGrid.style.display = 'none';
}
// 2. NOW rebuild the collection from clean state
var currentCards = Array.from(document.querySelectorAll('.site-card'));
if (!q) {
// 3. No search - make everything visible
allGroups.forEach(function (g) {
g.classList.remove('group-hidden');
var cardsInGroup = g.querySelectorAll('.site-card');
cardsInGroup.forEach(function (c) {
c.classList.remove('search-hidden');
});
});
document.body.classList.remove('search-active');
return;
}
// 4. Filtering logic only runs on a clean state
isFiltering = true;
// ... rest of the filtering code
}
Key Changes
- Cleanup first: Move cards back to groups before rebuilding the index
- Correct containers: Append to
.sites-grid, not the group wrapper - Clean state: Only after cleanup do we query for "current" cards
- Defensive filtering: When clearing search, iterate through actual groups, not the stale
currentCardsarray
Lessons Learned
1. DOM State is Expensive
In React or Vue, you'd have a virtual DOM and reactive state. In vanilla JavaScript, you have the actual DOM. Every querySelectorAll is a live look at the DOM. If you're moving elements around, your collections become stale the moment you append a child.
Solution: Establish a clear "source of truth" and rebuild it at the right time. For us, that's after we've restored all cards to their original positions.
2. Defensive Programming > Clever Programming
The original code was trying to be efficient: "I'll just grab all cards once and reuse that collection." But efficiency doesn't matter if it's wrong.
The fixed code queries the DOM multiple times. It's "slower" but it's correct. In client-side JavaScript, querying 30 elements is nanoseconds. Being wrong is forever.
3. The Devil is in the DOM Structure
We had .card-group → .sites-grid → .site-card. The extra wrapper seemed clean, but it meant we needed to be precise about where cards live. If you can simplify your DOM structure, do it. Every wrapper is another place to lose track of children.
4. Console.log is Your Friend
When debugging this, I added console logs everywhere:
console.log('Cards in DOM:', document.querySelectorAll('.site-card').length);
console.log('Cards in search grid:', searchGrid.children.length);
console.log('Current cards array:', currentCards.length);
Immediately, I saw the mismatch: 18 cards in the DOM, 3 cards in the search grid, but currentCards had 21. Mystery solved: duplicate references.
The Bigger Picture
This bug taught us something about building "simple" tools: state management is hard, even without state. We weren't using Redux, MobX, or even React's useState. We just had cards in the DOM. And yet, we still managed to create a race condition.
It reminded me of the classic computer science wisdom: "There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors." We hit the trifecta:
- Cache invalidation: Our
currentCardsarray was a cache of DOM state that we never invalidated - Naming things:
findOriginalGroupdidn't check if the card was currently in a group - Off-by-one errors: We were appending to the wrong container, misaligning our entire layout
Bonus: Real-Time Visual Feedback
One of the cooler parts of our hub is the physics-based search visualization. When you search, matching category "pills" zoom to the center, while non-matching ones drift to the edges. It's a subtle UX touch that makes search feel alive.
Here's how it works in the fixed code:
pills.forEach(function (p) {
if (isFiltering) {
if (p.matched) {
// Attract to center
var dx = centerX - p.x;
var dy = centerY - p.y;
p.vx += dx * 0.003;
p.vy += dy * 0.003;
} else {
// Drift to orbit position
var angle = (i / pills.length) * Math.PI * 2;
p._orbitX = centerX + Math.cos(angle) * radius;
p._orbitY = centerY + Math.sin(angle) * radius;
// ... spring physics to orbit
}
}
// ... damping, boundary checks, etc.
});
The physics makes the search experience feel responsive and playful. But it also adds another layer of state: pill positions, velocities, orbit targets. If we lose track of our cards, we lose the visual feedback too.
Pro Tip
When you add physics or animations, you're adding temporal state. The DOM at frame 1 is different from frame 60. Make sure your filtering logic doesn't interrupt the physics simulation mid-tick.
Conclusion
Fixing this bug was a reminder that "simple" UIs hide complex state management. We weren't building a real-time collaborative editor or a multiplayer game. We were just moving cards around. And yet, we still managed to create race conditions, stale state, and layout bugs.
The fix wasn't about adding Redux or rewriting in React. It was about being explicit:
- Explicit about DOM state vs. in-memory state
- Explicit about when to rebuild collections
- Explicit about which container holds which children
- Explicit about cleanup before new operations
Sometimes the best code isn't clever—it's clear.
And if you're wondering: yes, the search works now. Try it at neorgon.com. Type something, hit backspace, and watch the cards swarm back like starlings. It's beautiful when it works.