From 4430cfc3298c01a2019df9fa76a475e295841faa Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 19 May 2026 13:16:25 +0700 Subject: [PATCH] fix(frontend): suppress ?q= storage restore when ?qs= is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `/knowledges` is opened via a `?qs=…` link, `useTableState`'s mount-time restore was injecting the previous session's `?q=` from storage on top of it — so a clean semantic-search URL silently became `?qs=foo&q=python` and the user got a narrowed result set they didn't ask for. Added `skipRestore` to `useTableState` (defaults to `false`, captured via a ref at mount so a later toggle doesn't replay a restore the user has worked past). `knowledges.tsx` passes `searchParams.has('qs')` so the restore is skipped exactly when the parameter is part of the landing URL. Mirror-to-storage on subsequent filter changes is unaffected — a client filter the user actually types still persists to storage for the next session. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/hooks/use-table-state.ts | 22 ++++++++++++++++++++ frontend/src/pages/knowledges/knowledges.tsx | 15 ++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/use-table-state.ts b/frontend/src/hooks/use-table-state.ts index 3507963e..e2d84b04 100644 --- a/frontend/src/hooks/use-table-state.ts +++ b/frontend/src/hooks/use-table-state.ts @@ -36,6 +36,18 @@ interface UseTableStateOptions { filterParamName?: string; /** Query string param for the 1-based page number. Default `URL_PARAMS.PAGE` (`'page'`). */ pageParamName?: string; + /** + * Suppresses the one-time storage → URL restore on mount. Use on pages + * that route the `?q=` filter alongside a sibling URL parameter with + * its own filter semantics — e.g. `/knowledges` combines `?q=` (client + * text filter) with `?qs=` (server semantic search). Restoring a + * previously-typed `?q=` on top of a freshly-opened `?qs=` link would + * silently narrow the search result the user navigated to. The caller + * decides per-mount whether restore should fire; the value is captured + * via a ref at mount time so a later toggle doesn't replay a restore + * the user has since worked past. + */ + skipRestore?: boolean; /** * Stable storage key for persisting the filter value (a fresh tab without * `?=` resumes from here). Defaults to @@ -86,6 +98,7 @@ export function useTableState(options: UseTableStateOptions = {}): UseTableState debounceMs = 200, filterParamName = URL_PARAMS.QUERY, pageParamName = URL_PARAMS.PAGE, + skipRestore = false, storageKey: explicitStorageKey, } = options; @@ -154,6 +167,11 @@ export function useTableState(options: UseTableStateOptions = {}): UseTableState // exactly once per storageKey rotation — `restoredForKeyReference` // guards against repeating the replay on every render. const restoredForKeyReference = useRef(null); + // Snapshot the `skipRestore` decision at mount via a ref so a later + // toggle (e.g. the user clearing `?qs=`) doesn't fire the restore + // mid-session — by then they've already engaged with the page and a + // surprise URL mutation would be jarring. + const skipRestoreRef = useRef(skipRestore); useEffect(() => { if (restoredForKeyReference.current === storageKey) { @@ -162,6 +180,10 @@ export function useTableState(options: UseTableStateOptions = {}): UseTableState restoredForKeyReference.current = storageKey; + if (skipRestoreRef.current) { + return; + } + if (filter.length > 0) { // URL already has a value — that beats storage. Mirror it back // into storage so a fresh tab without `?q=` resumes from this diff --git a/frontend/src/pages/knowledges/knowledges.tsx b/frontend/src/pages/knowledges/knowledges.tsx index aefbd140..308707ac 100644 --- a/frontend/src/pages/knowledges/knowledges.tsx +++ b/frontend/src/pages/knowledges/knowledges.tsx @@ -2,7 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { Ellipsis, LibraryBig, Loader2, Pencil, PencilLine, Plus, Trash } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import type { BadgeVariant } from '@/components/ui/badge'; @@ -27,7 +27,7 @@ import { SidebarTrigger } from '@/components/ui/sidebar'; import { StatusCard } from '@/components/ui/status-card'; import { KnowledgeDocType } from '@/graphql/types'; import { useTableState } from '@/hooks/use-table-state'; -import { mergeHrefWithSearchParams } from '@/lib/url-params'; +import { mergeHrefWithSearchParams, URL_PARAMS } from '@/lib/url-params'; import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider'; const docTypeBadgeVariant: Record = { @@ -63,7 +63,16 @@ function Knowledges() { const [isRenameLoading, setIsRenameLoading] = useState(false); const editingInputRef = useRef(null); - const { filter, setFilter } = useTableState(); + // When the user lands on a `?qs=` link (server semantic search) we + // must suppress the storage→URL `?q=` restore. Re-injecting a previous + // session's client filter on top of a fresh semantic-search URL would + // silently narrow the result set behind the user's back. + // `useTableState` reads `skipRestore` once at mount via a ref, so a + // later toggle (user clears `?qs=`) won't replay the restore. + const [initialParams] = useSearchParams(); + const { filter, setFilter } = useTableState({ + skipRestore: initialParams.has(URL_PARAMS.SEARCH), + }); const handleOpen = useCallback( (id: string) => {