Fix search results flickering between old and new queries

When searching from the sidebar while already on /search, the
SearchPage component stayed mounted and its internal state (allEvents,
isLoading) from the previous search would briefly flash before the
new query's effect could clear it.

Fix: wrap SearchPage with a keyed component that uses ?q as the React
key. When the query changes, React unmounts the old instance entirely
and mounts a fresh one — all state initializes cleanly from the new
URL with no transitional renders showing stale data.

Closes #72
This commit is contained in:
Mary Kate Fain
2026-03-07 14:01:27 -06:00
parent b933abaa2c
commit 15df41d59f
3 changed files with 19 additions and 29 deletions
+8 -2
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes, useSearchParams } from "react-router-dom";
import { getExtraKindDef } from "./lib/extraKinds";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { ScrollToTop } from "./components/ScrollToTop";
@@ -60,6 +60,12 @@ function ProfileRedirect() {
return <Navigate to={profileUrl} replace />;
}
/** Forces SearchPage to remount when ?q changes, preventing stale results from flickering. */
function SearchPageKeyed() {
const [searchParams] = useSearchParams();
return <SearchPage key={searchParams.get('q') ?? ''} />;
}
export function AppRouter() {
return (
<AudioPlayerProvider>
@@ -73,7 +79,7 @@ export function AppRouter() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/search" element={<SearchPageKeyed />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
+2 -14
View File
@@ -1,5 +1,5 @@
import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useFeedSettings } from './useFeedSettings';
import { useMuteList } from './useMuteList';
import { useContentFilters } from './useContentFilters';
@@ -137,14 +137,6 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Track the query that produced the current allEvents so we can
// synchronously discard stale results during render (not after).
const lastQueryRef = useRef(query);
const stale = lastQueryRef.current !== query;
if (stale) {
lastQueryRef.current = query;
}
// Resolve authorPubkeys: accept hex or npub-encoded entries
const resolvedAuthorPubkeys = useMemo(() => {
if (!options.authorPubkeys || options.authorPubkeys.length === 0) return undefined;
@@ -332,10 +324,6 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// Apply client-side filters (including mute filtering and content filters) without restarting the stream
const posts = useMemo(() => {
// When the query just changed, allEvents still holds stale data from the
// previous search (the clearing effect hasn't run yet). Return empty to
// prevent old results from flashing for one render cycle.
if (stale) return [];
const authorSet = resolvedAuthorPubkeys ? new Set(resolvedAuthorPubkeys) : undefined;
return allEvents.filter((event) => {
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
@@ -345,7 +333,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
return filterEvent(event, options, query);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific options fields instead of the whole object for granular reactivity
}, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkeys, shouldFilterEvent, authorPubkeysKey, stale]);
}, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkeys, shouldFilterEvent, authorPubkeysKey]);
return { posts, isLoading };
}
+9 -13
View File
@@ -96,10 +96,6 @@ export function SearchPage() {
// Local input state for the search field (avoids trimming while typing)
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') ?? '');
// The effective query used for data fetching — derived directly from URL
// params so it is never one render behind when navigating from the sidebar.
const effectiveQuery = searchParams.get('q') ?? '';
const [filtersOpen, setFiltersOpen] = useState(false);
// ── Filter state — all derived from URL params ──────────────────────────
@@ -275,7 +271,7 @@ export function SearchPage() {
const parts: string[] = bridged.length > 0
? bridged.map(p => `protocol:${p}`)
: ['protocol:nostr'];
if (effectiveQuery.trim()) parts.push(effectiveQuery.trim());
if (searchQuery.trim()) parts.push(searchQuery.trim());
if (language !== 'global') parts.push(`language:${language}`);
const isDedicatedKindQuery = !kindsOverride && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
if (!isDedicatedKindQuery && !hasKindMediaConflict) {
@@ -286,7 +282,7 @@ export function SearchPage() {
if (sort === 'hot') parts.push('sort:hot');
else if (sort === 'trending') parts.push('sort:trending');
return parts.join(' ');
}, [effectiveQuery, language, mediaType, protocols, kindsOverride, hasKindMediaConflict, sort]);
}, [searchQuery, language, mediaType, protocols, kindsOverride, hasKindMediaConflict, sort]);
// Active filter labels for the summary / empty state hints
const activeFilterLabels = useMemo(() => {
@@ -334,11 +330,11 @@ export function SearchPage() {
// Build a standard NIP-01 TabFilter from the current search state
const currentFilter = useMemo<TabFilter>(() => {
const filter: TabFilter = {};
if (effectiveQuery.trim()) filter.search = effectiveQuery.trim();
if (searchQuery.trim()) filter.search = searchQuery.trim();
if (kindsOverride && kindsOverride.length > 0) filter.kinds = kindsOverride;
if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
return filter;
}, [effectiveQuery, kindsOverride, authorScope, authorPubkeys]);
}, [searchQuery, kindsOverride, authorScope, authorPubkeys]);
const alreadySaved = savedFeeds.some(
(f) => JSON.stringify(f.filter) === JSON.stringify(currentFilter),
@@ -376,7 +372,7 @@ export function SearchPage() {
? authorPubkeys
: undefined;
const { posts, isLoading: postsLoading } = useStreamPosts(effectiveQuery, {
const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, {
includeReplies,
mediaType,
language,
@@ -385,7 +381,7 @@ export function SearchPage() {
authorPubkeys: streamAuthorPubkeys,
sort,
});
const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? effectiveQuery : '');
const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? searchQuery : '');
return (
<main className="">
@@ -704,7 +700,7 @@ export function SearchPage() {
)}
{/* NIP-50 search query debug block */}
{effectiveQuery.trim() && (
{searchQuery.trim() && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -736,7 +732,7 @@ export function SearchPage() {
<NoteCard key={event.id} event={event} />
))}
</div>
) : effectiveQuery.trim() ? (
) : searchQuery.trim() ? (
<EmptyState
message="No posts found matching your search."
activeFilters={activeFilterLabels}
@@ -766,7 +762,7 @@ export function SearchPage() {
</div>
<div>
{effectiveQuery.trim() ? (
{searchQuery.trim() ? (
profilesLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (