Part of the MediaBridge series.
Per-Bucket Search vs Global Search
The single-bucket search described in the previous post runs one WebSocket and searches one bucket from a given prefix. Global search is different: it searches every bucket the user has access to simultaneously, merging results from all of them into a single view.
A user assigned to 10 buckets gets 10 WebSocket connections opened in parallel. Results from all 10 stream in at the same time. Each bucket runs its own DFS traversal independently.
The Hook
useGlobalSearch is a React hook that manages the lifecycle of all concurrent WebSocket connections for a global search session.
export function useGlobalSearch(): UseGlobalSearchReturn {
const [results, setResults] = useState<GlobalSearchResult[]>([]);
const [bucketsDone, setBucketsDone] = useState(0);
const [bucketsTotal, setBucketsTotal] = useState(0);
const [bucketStates, setBucketStates] = useState<Record<string, BucketState>>({});
const socketsRef = useRef<WebSocket[]>([]);
const generationRef = useRef(0);
// ...
}
socketsRef holds every open socket. generationRef is a counter that tracks which search is current.
Opening One Socket per Bucket
When search(buckets, q) is called:
- All existing sockets are closed
- The generation counter increments
- State resets (results cleared, done counts zeroed)
- One WebSocket is opened per bucket
const search = useCallback((buckets: MinBucket[], q: string) => {
close();
const gen = ++generationRef.current;
setResults([]);
setBucketsDone(0);
setBucketsTotal(buckets.length);
const token = localStorage.getItem('mb_token') || '';
const wsBase = CONFIG.api.baseUrl.replace(/^http/, 'ws');
for (const bucket of buckets) {
const ws = new WebSocket(`${wsBase}/buckets/${bucket.id}/search`);
socketsRef.current.push(ws);
ws.onopen = () => {
ws.send(JSON.stringify({ token, q, prefix: '', scope: 'full' }));
};
// ...
}
}, [close]);
Every socket sends the same query with scope: 'full' from the bucket root. Each runs its own DFS traversal on the server side - they are completely independent. The server does not coordinate them.
The Generation Counter
The generation counter is the key piece that prevents stale callbacks from polluting results.
Consider a user who types a search term, results start arriving, then they type something new. The old sockets are closed, but some callbacks may still be in flight in the JavaScript event loop when the new search starts. Without a guard, those callbacks would append old results to the new search.
The guard is a simple integer check:
ws.onmessage = (event: MessageEvent) => {
if (generationRef.current !== gen) return;
// ... handle message
};
const markDone = (failed: boolean) => {
if (settled || generationRef.current !== gen) return;
// ...
};
gen is captured at the start of the search call. generationRef.current is the live counter. If they differ, the callback knows it belongs to an old search and discards the message. This is safe even across async boundaries because React refs are mutable and synchronously accessible.
settled is a per-socket boolean that prevents double-counting. A socket can close after complete has already fired (the close event fires even after the socket finishes normally). The settled flag ensures markDone only runs once per socket regardless of the order events arrive.
Per-Bucket State
The hook tracks state per bucket, not just in aggregate:
export interface BucketState {
name: string;
done: boolean;
failed: boolean;
currentFolder: string | null;
}
currentFolder updates on every traversing or cache_hit message:
if (msg.type === 'traversing' || msg.type === 'cache_hit') {
setBucketStates(prev => ({
...prev,
[bucket.id]: { ...prev[bucket.id], currentFolder: msg.folder as string },
}));
}
This lets the UI show something like “Searching projects/marketing/…” per bucket while the traversal is running. When a bucket completes, done: true is set and currentFolder clears.
The aggregate progress (bucketsDone / bucketsTotal) comes from incrementing bucketsDone in markDone. A bucket that fails (onerror or an error message) also increments the done count so the progress bar does not stall.
Results Merge as They Arrive
Each result message appends to the shared results array:
} else if (msg.type === 'result') {
setResults(prev => [...prev, {
resource_name: msg.item.resource_name,
prefix: msg.item.prefix,
full_path: msg.item.full_path,
type: msg.item.type,
file_size: msg.item.file_size ?? null,
uploaded_at: msg.item.uploaded_at ?? null,
bucketId: bucket.id,
bucketName: bucket.display_name,
bucketRootPath: bucket.root_path || '',
}]);
}
Results from different buckets interleave as they arrive. There is no waiting for all buckets to finish before showing results - the first match from the fastest bucket appears immediately. Results include bucketId and bucketName so the UI can show which bucket each result came from and construct the correct navigation path.
Cleanup
The clear function closes all sockets and resets all state. It also increments the generation counter, which ensures any in-flight callbacks from the cleared sockets are discarded:
const clear = useCallback(() => {
generationRef.current++;
close();
setResults([]);
setBucketsDone(0);
setBucketsTotal(0);
setBucketStates({});
}, [close]);
This is called when the user clears the search input or leaves the global search view. React’s useEffect cleanup can call clear on unmount to ensure no WebSocket callbacks fire after the component is gone.