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:
+8
-2
@@ -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 />} />
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user