From cebded83832ef67d2d100a616f83c1dabeb09e04 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 01:20:04 -0600 Subject: [PATCH] Fix infinite tab loop on SearchPage by deriving tab from URL params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activeTab state and searchParams were synced bidirectionally via two competing useEffect hooks. When navigating between tabs via sidebar links (e.g., 'View All'), setSearchParams produced a new reference, re-triggering the URL→state effect with a stale closure, causing an infinite loop. Fix: derive activeTab directly from searchParams (single source of truth) and update the URL via a callback instead of a separate state variable. --- src/pages/SearchPage.tsx | 75 ++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 52293d6c..b748b78c 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,6 +1,6 @@ import { useSeoMeta } from '@unhead/react'; import { ChevronUp, ChevronDown, Search as SearchIcon, Flame, TrendingUp, Swords, Image, Video, Film, Languages } from 'lucide-react'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { MainLayout } from '@/components/MainLayout'; import { NoteCard } from '@/components/NoteCard'; @@ -24,6 +24,12 @@ import { nip19 } from 'nostr-tools'; type TabType = 'posts' | 'trends' | 'accounts'; +const VALID_TABS: TabType[] = ['posts', 'trends', 'accounts']; + +function parseTab(value: string | null): TabType { + return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts'; +} + export function SearchPage() { useSeoMeta({ title: 'Search | Mew', @@ -32,14 +38,53 @@ export function SearchPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const initialQuery = searchParams.get('q') ?? ''; - const initialTab = searchParams.get('tab') as TabType | null; - const [activeTab, setActiveTab] = useState(initialTab === 'trends' || initialTab === 'accounts' ? initialTab : 'posts'); - const [searchQuery, setSearchQuery] = useState(initialQuery); + // Derive tab directly from URL — single source of truth (no separate state) + const activeTab = parseTab(searchParams.get('tab')); + + // Local input state for the search field (avoids trimming while typing) + const [searchQuery, setSearchQuery] = useState(searchParams.get('q') ?? ''); const [filtersOpen, setFiltersOpen] = useState(true); const [trendSort, setTrendSort] = useState('hot'); + // Update tab in URL without a feedback loop + const setActiveTab = useCallback((tab: TabType) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (tab === 'posts') { + next.delete('tab'); + } else { + next.set('tab', tab); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + + // Sync search query state → URL (only when the trimmed value actually differs) + useEffect(() => { + const currentQ = searchParams.get('q') ?? ''; + const trimmed = searchQuery.trim(); + if (trimmed !== currentQ) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (trimmed) { + next.set('q', trimmed); + } else { + next.delete('q'); + } + return next; + }, { replace: true }); + } + }, [searchQuery, searchParams, setSearchParams]); + + // Sync URL → search query state (e.g., sidebar search or browser navigation) + useEffect(() => { + const q = searchParams.get('q') ?? ''; + if (q !== searchQuery.trim()) { + setSearchQuery(q); + } + }, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps + // If the search query is a Nostr identifier, redirect immediately useEffect(() => { const path = getNostrIdentifierPath(searchQuery); @@ -48,26 +93,6 @@ export function SearchPage() { } }, [searchQuery, navigate]); - // Sync search query and tab to URL params - useEffect(() => { - const params: Record = {}; - if (searchQuery.trim()) params.q = searchQuery.trim(); - if (activeTab !== 'posts') params.tab = activeTab; - setSearchParams(params, { replace: true }); - }, [searchQuery, activeTab, setSearchParams]); - - // Update search query when URL params change externally (e.g., from sidebar search) - useEffect(() => { - const q = searchParams.get('q') ?? ''; - if (q && q !== searchQuery) { - setSearchQuery(q); - } - const tab = searchParams.get('tab') as TabType | null; - if (tab && tab !== activeTab) { - setActiveTab(tab); - } - }, [searchParams]); - // Search filters const [includeReplies, setIncludeReplies] = useState(true); const [mediaType, setMediaType] = useState<'all' | 'images' | 'videos' | 'vines' | 'none'>('all');