From acd28749c8f82cf4b214bec00a3faeaaf73d2fec Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:34:31 -0400 Subject: [PATCH] Auto-evict the oldest search tab at capacity, raise the limit to 20 (#442) --- .../features/backlinks/BacklinksPage.tsx | 4 - .../backlinks/BacklinksSearchCard.tsx | 24 +----- .../features/domain/DomainOverviewPage.tsx | 35 +-------- .../keywords/hooks/useKeywordControlsForm.ts | 73 +------------------ .../keywords/page/KeywordResearchPage.tsx | 48 +----------- .../state/useKeywordResearchController.ts | 12 --- .../search-tabs/useSearchTabNavigation.ts | 9 +-- .../search-tabs/useSearchTabs.test.ts | 61 +++++++++++++++- .../features/search-tabs/useSearchTabs.ts | 41 ++++------- 9 files changed, 83 insertions(+), 224 deletions(-) diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index c38cf98..53aa443 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -193,10 +193,6 @@ export function BacklinksPage({ - searchTabs.canOpenTab(toBacklinksTabInput(values)) - } - tabLimit={searchTabs.limit} onSubmit={(values) => { searchTabs.openTab(toBacklinksTabInput(values)); navigateToBacklinksSearch(navigate, values); diff --git a/src/client/features/backlinks/BacklinksSearchCard.tsx b/src/client/features/backlinks/BacklinksSearchCard.tsx index 92a6e91..8c6b301 100644 --- a/src/client/features/backlinks/BacklinksSearchCard.tsx +++ b/src/client/features/backlinks/BacklinksSearchCard.tsx @@ -18,8 +18,6 @@ type SearchDraft = Pick; function getBacklinksValidationErrors( value: SearchDraft, shouldValidateUntouchedField: boolean, - canOpenSearch?: (value: SearchDraft) => boolean, - tabLimit?: number, ) { if (!value.target.trim()) { if (!shouldValidateUntouchedField) { @@ -33,34 +31,17 @@ function getBacklinksValidationErrors( }); } - const normalizedValue = { - ...value, - target: value.target.trim(), - }; - - if (canOpenSearch && !canOpenSearch(normalizedValue)) { - return createFormValidationErrors({ - fields: { - target: `Close a tab to open more searches (max ${tabLimit ?? 8}).`, - }, - }); - } - return null; } export function BacklinksSearchCard({ - canOpenSearch, errorMessage, initialValues, onSubmit, - tabLimit, }: { - canOpenSearch?: (values: SearchDraft) => boolean; errorMessage: string | null; initialValues: SearchDraft; onSubmit: (values: SearchDraft) => void; - tabLimit?: number; }) { const [userSelectedScope, setUserSelectedScope] = useState(false); const form = useForm({ @@ -70,11 +51,8 @@ export function BacklinksSearchCard({ getBacklinksValidationErrors( value, shouldValidateFieldOnChange(formApi, "target"), - canOpenSearch, - tabLimit, ), - onSubmit: ({ value }) => - getBacklinksValidationErrors(value, true, canOpenSearch, tabLimit), + onSubmit: ({ value }) => getBacklinksValidationErrors(value, true), }, onSubmit: ({ value }) => { const target = value.target.trim(); diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index c782046..fb51f8f 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -482,39 +482,6 @@ export function DomainOverviewPage({ navigateToInput: navigateToSearchTab, }); - const handleSearchSubmit = useCallback( - (event: FormEvent) => { - const values = state.controlsForm.state.values; - const target = normalizeDomainTarget(values.domain); - if (!target) { - state.handleSearchSubmit(event); - return; - } - - const nextTabInput: SearchTabInput = { - type: "domain", - domain: target, - subdomains: values.subdomains, - locationCode: values.locationCode, - }; - - if (!searchTabs.canOpenTab(nextTabInput)) { - event.preventDefault(); - state.controlsForm.setErrorMap({ - onSubmit: createFormValidationErrors({ - fields: { - domain: `Close a tab to open more searches (max ${searchTabs.limit}).`, - }, - }), - }); - return; - } - - state.handleSearchSubmit(event); - }, - [searchTabs, state], - ); - const tabControls = routeState.domain ? (
@@ -555,7 +522,7 @@ export function DomainOverviewPage({ state.applySort(sort, getDefaultSortOrder(sort)) } diff --git a/src/client/features/keywords/hooks/useKeywordControlsForm.ts b/src/client/features/keywords/hooks/useKeywordControlsForm.ts index 92d381a..024fed5 100644 --- a/src/client/features/keywords/hooks/useKeywordControlsForm.ts +++ b/src/client/features/keywords/hooks/useKeywordControlsForm.ts @@ -11,22 +11,12 @@ import { } from "@/client/features/keywords/keywordResearchTypes"; import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions"; -type KeywordTabValidationInput = { - keyword: string; - locationCode: number | undefined; - resultLimit: ResultLimit; - mode: KeywordMode; - clickstream: boolean; -}; - type UseKeywordControlsFormInput = { keywordInput: string; locationCode: number; resultLimit: ResultLimit; keywordMode: KeywordMode; clickstream: boolean; - getOpenKeywordTabs?: () => readonly KeywordTabValidationInput[]; - keywordTabsLimit?: number; }; export type KeywordControlsValues = { @@ -66,62 +56,6 @@ function getKeywordSearchValidationErrors( return null; } -function getKeywordTabCapacityError( - value: KeywordControlsValues, - openKeywordTabs: readonly KeywordTabValidationInput[] | undefined, - keywordTabsLimit: number | undefined, -) { - if (!openKeywordTabs || keywordTabsLimit == null) return null; - - const keywords = parseKeywordInput(value.keyword); - if (keywords.length === 0) return null; - - let simulatedOpenTabs = [...openKeywordTabs]; - let skippedCount = 0; - - for (const keyword of keywords) { - const input = { - keyword, - locationCode: value.locationCode, - resultLimit: value.resultLimit, - mode: value.mode, - clickstream: value.clickstream, - }; - const alreadyOpen = simulatedOpenTabs.some((tab) => - keywordTabMatches(tab, input), - ); - if (alreadyOpen) continue; - - if (simulatedOpenTabs.length >= keywordTabsLimit) { - skippedCount += 1; - continue; - } - - simulatedOpenTabs = [...simulatedOpenTabs, input]; - } - - if (skippedCount === 0) return null; - - return createFormValidationErrors({ - fields: { - keyword: `${skippedCount} keyword${skippedCount === 1 ? "" : "s"} skipped - close a tab to open more (max ${keywordTabsLimit}).`, - }, - }); -} - -function keywordTabMatches( - tab: KeywordTabValidationInput, - input: KeywordTabValidationInput, -) { - return ( - tab.keyword === input.keyword && - tab.locationCode === input.locationCode && - tab.resultLimit === input.resultLimit && - tab.mode === input.mode && - tab.clickstream === input.clickstream - ); -} - export function useKeywordControlsForm( input: UseKeywordControlsFormInput, onSubmit: (value: KeywordControlsValues) => void, @@ -142,12 +76,7 @@ export function useKeywordControlsForm( false, ), onSubmit: ({ value }) => - getKeywordSearchValidationErrors(value, true, true) ?? - getKeywordTabCapacityError( - value, - input.getOpenKeywordTabs?.(), - input.keywordTabsLimit, - ), + getKeywordSearchValidationErrors(value, true, true), }, onSubmit: ({ value }) => { onSubmit(value); diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index a9ea173..2d885d1 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { AlertCircle, ArrowLeft } from "lucide-react"; import { getErrorCode } from "@/client/lib/error-messages"; import { BILLING_ROUTE } from "@/shared/billing"; @@ -132,14 +132,10 @@ export function KeywordResearchPage(input: Props) { clickstream: value.clickstream, })); - let activeInput: KeywordSearchTabInput | null = null; for (const tabInput of inputs) { - const result = searchTabs.openTab(tabInput); - if (result.tab?.input.type === "keyword") { - activeInput = result.tab.input; - } + searchTabs.openTab(tabInput); } - if (activeInput) navigateToKeywordInput(activeInput); + navigateToKeywordInput(inputs.at(-1) ?? null); }, [navigateToKeywordInput, searchTabs], ); @@ -147,24 +143,6 @@ export function KeywordResearchPage(input: Props) { searchTabs.setActiveTab(null); navigateToKeywordInput(null); }, [navigateToKeywordInput, searchTabs]); - const getOpenKeywordTabs = useCallback( - () => - searchTabs.tabs.flatMap((tab) => - tab.input.type === "keyword" - ? [ - { - keyword: tab.input.keyword, - locationCode: tab.input.locationCode, - resultLimit: tab.input.resultLimit, - mode: tab.input.mode, - clickstream: tab.input.clickstream, - }, - ] - : [], - ), - [searchTabs.tabs], - ); - const controllerInput = useMemo( () => activeTab @@ -178,24 +156,18 @@ export function KeywordResearchPage(input: Props) { resultLimit: activeTab.input.resultLimit, keywordMode: activeTab.input.mode, clickstream: activeTab.input.clickstream, - getOpenKeywordTabs, - keywordTabsLimit: searchTabs.limit, } : { ...input, locationCode, displayedLocationCode, setPreferredLocationCode, - getOpenKeywordTabs, - keywordTabsLimit: searchTabs.limit, }, [ activeTab, - getOpenKeywordTabs, input, displayedLocationCode, locationCode, - searchTabs.limit, setPreferredLocationCode, ], ); @@ -203,20 +175,6 @@ export function KeywordResearchPage(input: Props) { ...controllerInput, onFormSubmit, }); - useEffect(() => { - controller.controlsForm.setErrorMap({ onSubmit: undefined }); - controller.controlsForm.setFieldMeta("keyword", (meta) => ({ - ...meta, - errorMap: { - ...meta.errorMap, - onSubmit: undefined, - }, - errorSourceMap: { - ...meta.errorSourceMap, - onSubmit: undefined, - }, - })); - }, [controller.controlsForm, searchTabs.tabs]); return (
diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index 9fed9c1..7a3ed5a 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -28,14 +28,6 @@ import { } from "./keywordControllerInternals"; import { useKeywordOverviewState } from "./useKeywordOverviewState"; -type OpenKeywordTabInput = { - keyword: string; - locationCode: number | undefined; - resultLimit: ResultLimit; - mode: KeywordMode; - clickstream: boolean; -}; - export type KeywordResearchControllerInput = { projectId: string; keywordInput: string; @@ -47,8 +39,6 @@ export type KeywordResearchControllerInput = { clickstream: boolean; sortField: SortField; sortDir: SortDir; - getOpenKeywordTabs?: () => readonly OpenKeywordTabInput[]; - keywordTabsLimit?: number; /** * Called when the user submits the search form. Lets the caller decide * whether the submission opens tabs or just rewrites the URL — the @@ -150,8 +140,6 @@ export function useKeywordResearchController( { ...input, locationCode: displayedLocationCode, - getOpenKeywordTabs: input.getOpenKeywordTabs, - keywordTabsLimit: input.keywordTabsLimit, }, (value) => { setPreferredLocationCode(value.locationCode); diff --git a/src/client/features/search-tabs/useSearchTabNavigation.ts b/src/client/features/search-tabs/useSearchTabNavigation.ts index d46f0e1..0f8bdd8 100644 --- a/src/client/features/search-tabs/useSearchTabNavigation.ts +++ b/src/client/features/search-tabs/useSearchTabNavigation.ts @@ -50,13 +50,10 @@ export function useSearchTabNavigation({ return; } - const result = openTab({ + openTab({ label: getLabel(urlInput), input: urlInput, }); - if (result.dropped) { - setActiveTab(null); - } }, [activeTabId, findMatchingTab, getLabel, openTab, setActiveTab, urlInput]); const selectTab = useCallback( @@ -85,7 +82,7 @@ export function useSearchTabNavigation({ const openSearchTab = useCallback( (input: SearchTabInput) => { closedInputKeysRef.current.delete(tabInputKey(input)); - return openTab({ + openTab({ label: getLabel(input), input, }); @@ -104,9 +101,7 @@ export function useSearchTabNavigation({ return { activeTabId: tabs.activeTabId, tabs: visibleTabs, - canOpenTab: tabs.canOpenTab, closeTab: closeSearchTab, - limit: tabs.limit, markTabViewed, openTab: openSearchTab, selectTab, diff --git a/src/client/features/search-tabs/useSearchTabs.test.ts b/src/client/features/search-tabs/useSearchTabs.test.ts index 5f5fbfb..cfa3050 100644 --- a/src/client/features/search-tabs/useSearchTabs.test.ts +++ b/src/client/features/search-tabs/useSearchTabs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseStoredState } from "./useSearchTabs"; +import { appendTabWithEviction, parseStoredState } from "./useSearchTabs"; +import type { SearchTab } from "./types"; function persistedTab(input: unknown) { return { @@ -11,6 +12,46 @@ function persistedTab(input: unknown) { }; } +function searchTab(index: number): SearchTab { + return { + id: `tab-${index}`, + label: `example-${index}.com`, + createdAt: index, + viewedAt: null, + input: { + type: "backlinks", + target: `example-${index}.com`, + scope: "domain", + }, + }; +} + +describe("appendTabWithEviction", () => { + it("appends without evicting below the limit", () => { + const tabs = Array.from({ length: 19 }, (_, index) => searchTab(index)); + + const next = appendTabWithEviction(tabs, searchTab(19)); + + expect(next).toHaveLength(20); + expect(next[0].id).toBe("tab-0"); + expect(next[19].id).toBe("tab-19"); + }); + + it("evicts the oldest tab at capacity", () => { + const tabs = Array.from({ length: 20 }, (_, index) => searchTab(index)); + + const next = appendTabWithEviction(tabs, searchTab(20)); + + expect(next).toHaveLength(20); + expect(next[0].id).toBe("tab-1"); + expect(next[19].id).toBe("tab-20"); + }); + + it("appends to an empty list", () => { + expect(appendTabWithEviction([], searchTab(0))).toHaveLength(1); + }); +}); + describe("parseStoredState", () => { it("keeps domain tabs persisted without a locationCode (default location)", () => { const state = parseStoredState({ @@ -77,6 +118,24 @@ describe("parseStoredState", () => { expect(state.tabs[0].input).toMatchObject({ locationCode: 2840 }); }); + it("keeps the newest tabs when stored state exceeds the limit", () => { + const state = parseStoredState({ + activeTabId: null, + tabs: Array.from({ length: 25 }, (_, index) => ({ + ...persistedTab({ + type: "backlinks", + target: `example-${index}.com`, + scope: "domain", + }), + id: `tab-${index}`, + })), + }); + + expect(state.tabs).toHaveLength(20); + expect(state.tabs[0].id).toBe("tab-5"); + expect(state.tabs[19].id).toBe("tab-24"); + }); + it("still rejects malformed tab inputs", () => { const state = parseStoredState({ activeTabId: null, diff --git a/src/client/features/search-tabs/useSearchTabs.ts b/src/client/features/search-tabs/useSearchTabs.ts index 4815308..4e63cd0 100644 --- a/src/client/features/search-tabs/useSearchTabs.ts +++ b/src/client/features/search-tabs/useSearchTabs.ts @@ -11,11 +11,6 @@ type OpenTabInput = { input: SearchTabInput; }; -type OpenTabResult = { - tab: SearchTab | null; - dropped: boolean; -}; - const EMPTY_STATE: TabsState = { tabs: [], activeTabId: null, @@ -23,7 +18,7 @@ const EMPTY_STATE: TabsState = { const CHANGE_EVENT = "search-tabs-change"; const stateCache = new Map(); -const SEARCH_TABS_LIMIT = 8; +const SEARCH_TABS_LIMIT = 20; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -136,7 +131,8 @@ export function parseStoredState(value: unknown): TabsState { }, ]; }) - .slice(0, SEARCH_TABS_LIMIT); + // Keep the newest tabs when over the limit, matching openTab's eviction. + .slice(-SEARCH_TABS_LIMIT); const activeTabId = typeof value.activeTabId === "string" && tabs.some((tab) => tab.id === value.activeTabId) @@ -194,6 +190,16 @@ function subscribe(onChange: () => void) { return () => window.removeEventListener(CHANGE_EVENT, onChange); } +// At capacity, evict the oldest tabs instead of refusing the new one. +// Exported for unit tests. +export function appendTabWithEviction( + tabs: SearchTab[], + next: SearchTab, +): SearchTab[] { + const kept = tabs.slice(Math.max(0, tabs.length - SEARCH_TABS_LIMIT + 1)); + return [...kept, next]; +} + function generateTabId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); @@ -209,22 +215,15 @@ export function useSearchTabs(key: string) { ); const openTab = useCallback( - ({ label, input }: OpenTabInput): OpenTabResult => { - let result: SearchTab | null = null; - let dropped = false; + ({ label, input }: OpenTabInput) => { update(key, (current) => { const inputKey = tabInputKey(input); const existing = current.tabs.find( (tab) => tabInputKey(tab.input) === inputKey, ); if (existing) { - result = existing; return { ...current, activeTabId: existing.id }; } - if (current.tabs.length >= SEARCH_TABS_LIMIT) { - dropped = true; - return current; - } const next: SearchTab = { id: generateTabId(), label, @@ -232,13 +231,11 @@ export function useSearchTabs(key: string) { createdAt: Date.now(), viewedAt: null, }; - result = next; return { - tabs: [...current.tabs, next], + tabs: appendTabWithEviction(current.tabs, next), activeTabId: next.id, }; }); - return { tab: result, dropped }; }, [key], ); @@ -314,12 +311,6 @@ export function useSearchTabs(key: string) { [state.tabs], ); - const canOpenTab = useCallback( - (input: SearchTabInput) => - Boolean(findMatchingTab(input)) || state.tabs.length < SEARCH_TABS_LIMIT, - [findMatchingTab, state.tabs.length], - ); - const activeTab = useMemo( () => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null, [state.activeTabId, state.tabs], @@ -329,10 +320,8 @@ export function useSearchTabs(key: string) { activeTab, activeTabId: state.activeTabId, tabs: state.tabs, - canOpenTab, closeTab, findMatchingTab, - limit: SEARCH_TABS_LIMIT, markTabViewed, openTab, setActiveTab,