Auto-evict the oldest search tab at capacity, raise the limit to 20 (#442)

This commit is contained in:
Ben Senescu 2026-08-01 10:34:31 -04:00 committed by GitHub
parent 32b1b1d38c
commit acd28749c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 83 additions and 224 deletions

View File

@ -193,10 +193,6 @@ export function BacklinksPage({
<BacklinksSearchCard <BacklinksSearchCard
errorMessage={overviewErrorMessage} errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues} initialValues={searchCardInitialValues}
canOpenSearch={(values) =>
searchTabs.canOpenTab(toBacklinksTabInput(values))
}
tabLimit={searchTabs.limit}
onSubmit={(values) => { onSubmit={(values) => {
searchTabs.openTab(toBacklinksTabInput(values)); searchTabs.openTab(toBacklinksTabInput(values));
navigateToBacklinksSearch(navigate, values); navigateToBacklinksSearch(navigate, values);

View File

@ -18,8 +18,6 @@ type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
function getBacklinksValidationErrors( function getBacklinksValidationErrors(
value: SearchDraft, value: SearchDraft,
shouldValidateUntouchedField: boolean, shouldValidateUntouchedField: boolean,
canOpenSearch?: (value: SearchDraft) => boolean,
tabLimit?: number,
) { ) {
if (!value.target.trim()) { if (!value.target.trim()) {
if (!shouldValidateUntouchedField) { 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; return null;
} }
export function BacklinksSearchCard({ export function BacklinksSearchCard({
canOpenSearch,
errorMessage, errorMessage,
initialValues, initialValues,
onSubmit, onSubmit,
tabLimit,
}: { }: {
canOpenSearch?: (values: SearchDraft) => boolean;
errorMessage: string | null; errorMessage: string | null;
initialValues: SearchDraft; initialValues: SearchDraft;
onSubmit: (values: SearchDraft) => void; onSubmit: (values: SearchDraft) => void;
tabLimit?: number;
}) { }) {
const [userSelectedScope, setUserSelectedScope] = useState(false); const [userSelectedScope, setUserSelectedScope] = useState(false);
const form = useForm({ const form = useForm({
@ -70,11 +51,8 @@ export function BacklinksSearchCard({
getBacklinksValidationErrors( getBacklinksValidationErrors(
value, value,
shouldValidateFieldOnChange(formApi, "target"), shouldValidateFieldOnChange(formApi, "target"),
canOpenSearch,
tabLimit,
), ),
onSubmit: ({ value }) => onSubmit: ({ value }) => getBacklinksValidationErrors(value, true),
getBacklinksValidationErrors(value, true, canOpenSearch, tabLimit),
}, },
onSubmit: ({ value }) => { onSubmit: ({ value }) => {
const target = value.target.trim(); const target = value.target.trim();

View File

@ -482,39 +482,6 @@ export function DomainOverviewPage({
navigateToInput: navigateToSearchTab, 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 ? ( const tabControls = routeState.domain ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
@ -555,7 +522,7 @@ export function DomainOverviewPage({
<DomainSearchCard <DomainSearchCard
controlsForm={state.controlsForm} controlsForm={state.controlsForm}
isLoading={state.isLoading} isLoading={state.isLoading}
onSubmit={handleSearchSubmit} onSubmit={state.handleSearchSubmit}
onSortChange={(sort) => onSortChange={(sort) =>
state.applySort(sort, getDefaultSortOrder(sort)) state.applySort(sort, getDefaultSortOrder(sort))
} }

View File

@ -11,22 +11,12 @@ import {
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions"; import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
type KeywordTabValidationInput = {
keyword: string;
locationCode: number | undefined;
resultLimit: ResultLimit;
mode: KeywordMode;
clickstream: boolean;
};
type UseKeywordControlsFormInput = { type UseKeywordControlsFormInput = {
keywordInput: string; keywordInput: string;
locationCode: number; locationCode: number;
resultLimit: ResultLimit; resultLimit: ResultLimit;
keywordMode: KeywordMode; keywordMode: KeywordMode;
clickstream: boolean; clickstream: boolean;
getOpenKeywordTabs?: () => readonly KeywordTabValidationInput[];
keywordTabsLimit?: number;
}; };
export type KeywordControlsValues = { export type KeywordControlsValues = {
@ -66,62 +56,6 @@ function getKeywordSearchValidationErrors(
return null; 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( export function useKeywordControlsForm(
input: UseKeywordControlsFormInput, input: UseKeywordControlsFormInput,
onSubmit: (value: KeywordControlsValues) => void, onSubmit: (value: KeywordControlsValues) => void,
@ -142,12 +76,7 @@ export function useKeywordControlsForm(
false, false,
), ),
onSubmit: ({ value }) => onSubmit: ({ value }) =>
getKeywordSearchValidationErrors(value, true, true) ?? getKeywordSearchValidationErrors(value, true, true),
getKeywordTabCapacityError(
value,
input.getOpenKeywordTabs?.(),
input.keywordTabsLimit,
),
}, },
onSubmit: ({ value }) => { onSubmit: ({ value }) => {
onSubmit(value); onSubmit(value);

View File

@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { AlertCircle, ArrowLeft } from "lucide-react"; import { AlertCircle, ArrowLeft } from "lucide-react";
import { getErrorCode } from "@/client/lib/error-messages"; import { getErrorCode } from "@/client/lib/error-messages";
import { BILLING_ROUTE } from "@/shared/billing"; import { BILLING_ROUTE } from "@/shared/billing";
@ -132,14 +132,10 @@ export function KeywordResearchPage(input: Props) {
clickstream: value.clickstream, clickstream: value.clickstream,
})); }));
let activeInput: KeywordSearchTabInput | null = null;
for (const tabInput of inputs) { for (const tabInput of inputs) {
const result = searchTabs.openTab(tabInput); searchTabs.openTab(tabInput);
if (result.tab?.input.type === "keyword") {
activeInput = result.tab.input;
} }
} navigateToKeywordInput(inputs.at(-1) ?? null);
if (activeInput) navigateToKeywordInput(activeInput);
}, },
[navigateToKeywordInput, searchTabs], [navigateToKeywordInput, searchTabs],
); );
@ -147,24 +143,6 @@ export function KeywordResearchPage(input: Props) {
searchTabs.setActiveTab(null); searchTabs.setActiveTab(null);
navigateToKeywordInput(null); navigateToKeywordInput(null);
}, [navigateToKeywordInput, searchTabs]); }, [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<ControllerProps>( const controllerInput = useMemo<ControllerProps>(
() => () =>
activeTab activeTab
@ -178,24 +156,18 @@ export function KeywordResearchPage(input: Props) {
resultLimit: activeTab.input.resultLimit, resultLimit: activeTab.input.resultLimit,
keywordMode: activeTab.input.mode, keywordMode: activeTab.input.mode,
clickstream: activeTab.input.clickstream, clickstream: activeTab.input.clickstream,
getOpenKeywordTabs,
keywordTabsLimit: searchTabs.limit,
} }
: { : {
...input, ...input,
locationCode, locationCode,
displayedLocationCode, displayedLocationCode,
setPreferredLocationCode, setPreferredLocationCode,
getOpenKeywordTabs,
keywordTabsLimit: searchTabs.limit,
}, },
[ [
activeTab, activeTab,
getOpenKeywordTabs,
input, input,
displayedLocationCode, displayedLocationCode,
locationCode, locationCode,
searchTabs.limit,
setPreferredLocationCode, setPreferredLocationCode,
], ],
); );
@ -203,20 +175,6 @@ export function KeywordResearchPage(input: Props) {
...controllerInput, ...controllerInput,
onFormSubmit, 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 ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">

View File

@ -28,14 +28,6 @@ import {
} from "./keywordControllerInternals"; } from "./keywordControllerInternals";
import { useKeywordOverviewState } from "./useKeywordOverviewState"; import { useKeywordOverviewState } from "./useKeywordOverviewState";
type OpenKeywordTabInput = {
keyword: string;
locationCode: number | undefined;
resultLimit: ResultLimit;
mode: KeywordMode;
clickstream: boolean;
};
export type KeywordResearchControllerInput = { export type KeywordResearchControllerInput = {
projectId: string; projectId: string;
keywordInput: string; keywordInput: string;
@ -47,8 +39,6 @@ export type KeywordResearchControllerInput = {
clickstream: boolean; clickstream: boolean;
sortField: SortField; sortField: SortField;
sortDir: SortDir; sortDir: SortDir;
getOpenKeywordTabs?: () => readonly OpenKeywordTabInput[];
keywordTabsLimit?: number;
/** /**
* Called when the user submits the search form. Lets the caller decide * Called when the user submits the search form. Lets the caller decide
* whether the submission opens tabs or just rewrites the URL the * whether the submission opens tabs or just rewrites the URL the
@ -150,8 +140,6 @@ export function useKeywordResearchController(
{ {
...input, ...input,
locationCode: displayedLocationCode, locationCode: displayedLocationCode,
getOpenKeywordTabs: input.getOpenKeywordTabs,
keywordTabsLimit: input.keywordTabsLimit,
}, },
(value) => { (value) => {
setPreferredLocationCode(value.locationCode); setPreferredLocationCode(value.locationCode);

View File

@ -50,13 +50,10 @@ export function useSearchTabNavigation({
return; return;
} }
const result = openTab({ openTab({
label: getLabel(urlInput), label: getLabel(urlInput),
input: urlInput, input: urlInput,
}); });
if (result.dropped) {
setActiveTab(null);
}
}, [activeTabId, findMatchingTab, getLabel, openTab, setActiveTab, urlInput]); }, [activeTabId, findMatchingTab, getLabel, openTab, setActiveTab, urlInput]);
const selectTab = useCallback( const selectTab = useCallback(
@ -85,7 +82,7 @@ export function useSearchTabNavigation({
const openSearchTab = useCallback( const openSearchTab = useCallback(
(input: SearchTabInput) => { (input: SearchTabInput) => {
closedInputKeysRef.current.delete(tabInputKey(input)); closedInputKeysRef.current.delete(tabInputKey(input));
return openTab({ openTab({
label: getLabel(input), label: getLabel(input),
input, input,
}); });
@ -104,9 +101,7 @@ export function useSearchTabNavigation({
return { return {
activeTabId: tabs.activeTabId, activeTabId: tabs.activeTabId,
tabs: visibleTabs, tabs: visibleTabs,
canOpenTab: tabs.canOpenTab,
closeTab: closeSearchTab, closeTab: closeSearchTab,
limit: tabs.limit,
markTabViewed, markTabViewed,
openTab: openSearchTab, openTab: openSearchTab,
selectTab, selectTab,

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseStoredState } from "./useSearchTabs"; import { appendTabWithEviction, parseStoredState } from "./useSearchTabs";
import type { SearchTab } from "./types";
function persistedTab(input: unknown) { function persistedTab(input: unknown) {
return { 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", () => { describe("parseStoredState", () => {
it("keeps domain tabs persisted without a locationCode (default location)", () => { it("keeps domain tabs persisted without a locationCode (default location)", () => {
const state = parseStoredState({ const state = parseStoredState({
@ -77,6 +118,24 @@ describe("parseStoredState", () => {
expect(state.tabs[0].input).toMatchObject({ locationCode: 2840 }); 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", () => { it("still rejects malformed tab inputs", () => {
const state = parseStoredState({ const state = parseStoredState({
activeTabId: null, activeTabId: null,

View File

@ -11,11 +11,6 @@ type OpenTabInput = {
input: SearchTabInput; input: SearchTabInput;
}; };
type OpenTabResult = {
tab: SearchTab | null;
dropped: boolean;
};
const EMPTY_STATE: TabsState = { const EMPTY_STATE: TabsState = {
tabs: [], tabs: [],
activeTabId: null, activeTabId: null,
@ -23,7 +18,7 @@ const EMPTY_STATE: TabsState = {
const CHANGE_EVENT = "search-tabs-change"; const CHANGE_EVENT = "search-tabs-change";
const stateCache = new Map<string, TabsState>(); const stateCache = new Map<string, TabsState>();
const SEARCH_TABS_LIMIT = 8; const SEARCH_TABS_LIMIT = 20;
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; 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 = const activeTabId =
typeof value.activeTabId === "string" && typeof value.activeTabId === "string" &&
tabs.some((tab) => tab.id === value.activeTabId) tabs.some((tab) => tab.id === value.activeTabId)
@ -194,6 +190,16 @@ function subscribe(onChange: () => void) {
return () => window.removeEventListener(CHANGE_EVENT, onChange); 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 { function generateTabId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) { if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID(); return crypto.randomUUID();
@ -209,22 +215,15 @@ export function useSearchTabs(key: string) {
); );
const openTab = useCallback( const openTab = useCallback(
({ label, input }: OpenTabInput): OpenTabResult => { ({ label, input }: OpenTabInput) => {
let result: SearchTab | null = null;
let dropped = false;
update(key, (current) => { update(key, (current) => {
const inputKey = tabInputKey(input); const inputKey = tabInputKey(input);
const existing = current.tabs.find( const existing = current.tabs.find(
(tab) => tabInputKey(tab.input) === inputKey, (tab) => tabInputKey(tab.input) === inputKey,
); );
if (existing) { if (existing) {
result = existing;
return { ...current, activeTabId: existing.id }; return { ...current, activeTabId: existing.id };
} }
if (current.tabs.length >= SEARCH_TABS_LIMIT) {
dropped = true;
return current;
}
const next: SearchTab = { const next: SearchTab = {
id: generateTabId(), id: generateTabId(),
label, label,
@ -232,13 +231,11 @@ export function useSearchTabs(key: string) {
createdAt: Date.now(), createdAt: Date.now(),
viewedAt: null, viewedAt: null,
}; };
result = next;
return { return {
tabs: [...current.tabs, next], tabs: appendTabWithEviction(current.tabs, next),
activeTabId: next.id, activeTabId: next.id,
}; };
}); });
return { tab: result, dropped };
}, },
[key], [key],
); );
@ -314,12 +311,6 @@ export function useSearchTabs(key: string) {
[state.tabs], [state.tabs],
); );
const canOpenTab = useCallback(
(input: SearchTabInput) =>
Boolean(findMatchingTab(input)) || state.tabs.length < SEARCH_TABS_LIMIT,
[findMatchingTab, state.tabs.length],
);
const activeTab = useMemo( const activeTab = useMemo(
() => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null, () => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null,
[state.activeTabId, state.tabs], [state.activeTabId, state.tabs],
@ -329,10 +320,8 @@ export function useSearchTabs(key: string) {
activeTab, activeTab,
activeTabId: state.activeTabId, activeTabId: state.activeTabId,
tabs: state.tabs, tabs: state.tabs,
canOpenTab,
closeTab, closeTab,
findMatchingTab, findMatchingTab,
limit: SEARCH_TABS_LIMIT,
markTabViewed, markTabViewed,
openTab, openTab,
setActiveTab, setActiveTab,