fix: use Tanstack Query for Keyword Research (#150)

* keywords: use query cache for research requests

* keywords: simplify research request dedupe
This commit is contained in:
Ben Senescu 2026-05-05 14:11:28 -04:00 committed by GitHub
parent 2fc568f73c
commit db1a1d723f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 223 additions and 255 deletions

View File

@ -1,4 +1,5 @@
import { useRef, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils"; import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
@ -9,7 +10,6 @@ import type {
KeywordSource, KeywordSource,
ResultLimit, ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchRow } from "@/types/keywords";
type AddSearchFn = ( type AddSearchFn = (
keyword: string, keyword: string,
@ -17,130 +17,153 @@ type AddSearchFn = (
locationName: string, locationName: string,
) => void; ) => void;
export function useKeywordResearchData(addSearch: AddSearchFn) { type KeywordResearchQueryInput = {
const [rows, setRows] = useState<KeywordResearchRow[]>([]); projectId: string;
const [hasSearched, setHasSearched] = useState(false); keywordInput: string;
const [lastSearchError, setLastSearchError] = useState(false); locationCode: number;
const [lastResultSource, setLastResultSource] = resultLimit: ResultLimit;
useState<KeywordSource>("related"); mode: KeywordMode;
const [lastUsedFallback, setLastUsedFallback] = useState(false); };
const [lastSearchKeyword, setLastSearchKeyword] = useState("");
const [lastSearchLocationCode, setLastSearchLocationCode] = useState( type KeywordResearchRequest = {
DEFAULT_LOCATION_CODE, projectId: string;
keywords: string[];
seedKeyword: string;
locationCode: number;
languageCode: string;
resultLimit: ResultLimit;
mode: KeywordMode;
};
const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
function parseSearchKeywords(value: string) {
return value
.split(/[\n,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
}
function buildKeywordResearchQueryKey(request: KeywordResearchRequest | null) {
return request
? [
"keywordResearch",
request.projectId,
request.keywords,
request.locationCode,
request.languageCode,
request.resultLimit,
request.mode,
]
: ["keywordResearch", "idle"];
}
export function useKeywordResearchData(
input: KeywordResearchQueryInput,
addSearch: AddSearchFn,
) {
const keywords = useMemo(
() => parseSearchKeywords(input.keywordInput),
[input.keywordInput],
); );
const [researchError, setResearchError] = useState<string | null>(null); const request = useMemo<KeywordResearchRequest | null>(() => {
const [researchMutationError, setResearchMutationError] = const seedKeyword = keywords[0] ?? "";
useState<unknown>(null); if (!seedKeyword) return null;
const [searchedKeyword, setSearchedKeyword] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Sequence token so a stale fetch (e.g. user fired a second search before
// the first resolved) can't overwrite state that belongs to a newer one.
const requestSeqRef = useRef(0);
const beginSearch = (seedKeyword: string, locationCode: number) => { return {
setResearchError(null); projectId: input.projectId,
setResearchMutationError(null); keywords,
setHasSearched(true); seedKeyword,
setLastSearchError(false); locationCode: input.locationCode,
setSearchedKeyword(seedKeyword); languageCode: getLanguageCode(input.locationCode),
setLastSearchKeyword(seedKeyword); resultLimit: input.resultLimit,
setLastSearchLocationCode(locationCode); mode: input.mode,
setIsLoading(true); };
}; }, [
input.locationCode,
input.mode,
input.projectId,
input.resultLimit,
keywords,
]);
const queryKey = useMemo(
() => buildKeywordResearchQueryKey(request),
[request],
);
const queryKeyString = JSON.stringify(queryKey);
const resetResearch = () => { const researchQuery = useQuery({
setRows([]); queryKey,
setHasSearched(false); queryFn: () => {
setLastSearchError(false); if (!request) {
setLastResultSource("related"); throw new Error("Keyword research query ran without request params");
setLastUsedFallback(false);
setLastSearchKeyword("");
setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
setResearchError(null);
setResearchMutationError(null);
setSearchedKeyword("");
setIsLoading(false);
};
const runSearch = async (
input: {
projectId: string;
keywords: string[];
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
},
handlers?: {
onSuccess?: (seedKeyword: string, rows: KeywordResearchRow[]) => void;
onError?: () => void;
},
) => {
const seedKeyword = input.keywords[0] ?? "";
const languageCode = getLanguageCode(input.locationCode);
const requestSeq = ++requestSeqRef.current;
const isStale = () => requestSeqRef.current !== requestSeq;
try {
const result = await researchKeywords({
data: {
keywords: input.keywords,
projectId: input.projectId,
locationCode: input.locationCode,
languageCode,
resultLimit: input.resultLimit,
mode: input.mode,
},
});
if (isStale()) return;
setResearchError(null);
setResearchMutationError(null);
setRows(result.rows);
setLastResultSource(result.source);
setLastUsedFallback(result.usedFallback);
captureClientEvent("keyword_research:search_complete", {
location_code: input.locationCode,
search_mode: input.mode,
result_count: result.rows.length,
});
if (seedKeyword) {
addSearch(
seedKeyword,
input.locationCode,
LOCATIONS[input.locationCode] || "Unknown",
);
} }
handlers?.onSuccess?.(seedKeyword, result.rows); return researchKeywords({
} catch (error) { data: {
if (isStale()) return; projectId: request.projectId,
setLastSearchError(true); keywords: request.keywords,
setRows([]); locationCode: request.locationCode,
setResearchMutationError(error); languageCode: request.languageCode,
setResearchError(getStandardErrorMessage(error, "Research failed.")); resultLimit: request.resultLimit,
handlers?.onError?.(); mode: request.mode,
} finally { },
if (!isStale()) setIsLoading(false); });
} },
}; enabled: request !== null,
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const handledSuccessKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!request || !researchQuery.isSuccess || !researchQuery.data) return;
if (handledSuccessKeyRef.current === queryKeyString) return;
handledSuccessKeyRef.current = queryKeyString;
captureClientEvent("keyword_research:search_complete", {
location_code: request.locationCode,
search_mode: request.mode,
result_count: researchQuery.data.rows.length,
});
addSearch(
request.seedKeyword,
request.locationCode,
LOCATIONS[request.locationCode] || "Unknown",
);
}, [
addSearch,
queryKeyString,
request,
researchQuery.data,
researchQuery.isSuccess,
]);
const hasSearched = request !== null;
const rows = hasSearched ? (researchQuery.data?.rows ?? []) : [];
const researchError =
hasSearched && researchQuery.isError
? getStandardErrorMessage(researchQuery.error, "Research failed.")
: null;
return { return {
rows, rows,
hasSearched, hasSearched,
lastSearchError, lastSearchError: hasSearched && researchQuery.isError,
lastResultSource, lastResultSource:
lastUsedFallback, researchQuery.data?.source ?? ("related" as KeywordSource),
lastSearchKeyword, lastUsedFallback: researchQuery.data?.usedFallback ?? false,
lastSearchLocationCode, lastSearchKeyword: request?.seedKeyword ?? "",
lastSearchLocationCode: request?.locationCode ?? DEFAULT_LOCATION_CODE,
researchError, researchError,
researchMutationError, researchMutationError: researchQuery.error,
searchedKeyword, searchedKeyword: request?.seedKeyword ?? "",
isLoading, isLoading: hasSearched && researchQuery.isPending,
beginSearch, researchQuery,
resetResearch, retryResearch: researchQuery.refetch,
runSearch,
}; };
} }

View File

@ -81,10 +81,7 @@ function KeywordResearchContent({
Go to Billing Go to Billing
</Link> </Link>
) : ( ) : (
<button <button className="btn btn-sm" onClick={controller.retrySearch}>
className="btn btn-sm"
onClick={() => controller.onSearch()}
>
Try again Try again
</button> </button>
)} )}

View File

@ -17,7 +17,6 @@ import type { SortDir, SortField } from "@/client/features/keywords/components";
import { import {
buildKeywordSearchKey, buildKeywordSearchKey,
getNextSortParams, getNextSortParams,
parseKeywordInput,
useSaveAndExportActions, useSaveAndExportActions,
} from "./keywordControllerActions"; } from "./keywordControllerActions";
import { import {
@ -45,6 +44,7 @@ export function useKeywordResearchController(
const state = useKeywordControllerState(input); const state = useKeywordControllerState(input);
const controlsForm = state.controlsForm; const controlsForm = state.controlsForm;
const setSearchParams = state.setSearchParams; const setSearchParams = state.setSearchParams;
const retryResearch = state.retryResearch;
const onSearch = useCallback( const onSearch = useCallback(
(overrides?: Partial<{ keyword: string; locationCode: number }>) => { (overrides?: Partial<{ keyword: string; locationCode: number }>) => {
@ -61,6 +61,10 @@ export function useKeywordResearchController(
[controlsForm], [controlsForm],
); );
const retrySearch = useCallback(() => {
void retryResearch();
}, [retryResearch]);
const handleSearchSubmit = useCallback( const handleSearchSubmit = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
@ -124,6 +128,7 @@ export function useKeywordResearchController(
removeHistoryItem: state.removeHistoryItem, removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError, researchError: state.researchError,
researchMutationError: state.researchMutationError, researchMutationError: state.researchMutationError,
retrySearch,
resetFilters: state.resetFilters, resetFilters: state.resetFilters,
rows: state.rows, rows: state.rows,
searchedKeyword: state.searchedKeyword, searchedKeyword: state.searchedKeyword,
@ -191,66 +196,41 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
lastSearchLocationCode, lastSearchLocationCode,
researchError, researchError,
researchMutationError, researchMutationError,
researchQuery,
searchedKeyword, searchedKeyword,
isLoading, isLoading,
beginSearch, retryResearch,
resetResearch, } = useKeywordResearchData(
runSearch, {
} = useKeywordResearchData(addSearch); projectId: input.projectId,
keywordInput: input.keywordInput,
locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
},
addSearch,
);
const setSearchParams = useKeywordSearchParams(); const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId); const saveMutation = useKeywordSaveMutation(input.projectId);
// Tracks the parameters used for the most recent search trigger so the const activeSearchKey = input.keywordInput.trim()
// URL-driven effect below doesn't re-fire after the form-submit path ? buildKeywordSearchKey({
// already kicked off a search for the same params. keyword: input.keywordInput,
const lastTriggerKeyRef = useRef<string | null>(null); locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
})
: null;
const triggerSearch = useCallback( const previousSearchKeyRef = useRef<string | null>(null);
(params: { const handledSerpSearchKeyRef = useRef<string | null>(null);
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
}) => {
const keywords = parseKeywordInput(params.keyword);
if (keywords.length === 0) return;
lastTriggerKeyRef.current = buildKeywordSearchKey(params); const clearActiveKeywordResult = useCallback(() => {
uiState.setSelectedKeyword(null); clearSelection();
clearSelection(); uiState.setSelectedKeyword(null);
setSerpKeyword(null); setSerpKeyword(null);
beginSearch(keywords[0] ?? "", params.locationCode); setSerpPage(0);
}, [clearSelection, setSerpKeyword, setSerpPage, uiState]);
void runSearch(
{
projectId: input.projectId,
keywords,
locationCode: params.locationCode,
resultLimit: params.resultLimit,
mode: params.mode,
},
{
onSuccess: (seedKeyword, nextRows) => {
if (nextRows.length === 0) {
setSerpKeyword(null);
return;
}
setSerpKeyword(seedKeyword);
setSerpPage(0);
},
},
);
},
[
beginSearch,
clearSelection,
input.projectId,
runSearch,
setSerpKeyword,
setSerpPage,
uiState,
],
);
const controlsForm = useKeywordControlsForm( const controlsForm = useKeywordControlsForm(
{ {
@ -269,63 +249,39 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
kLimit: value.resultLimit === 150 ? undefined : value.resultLimit, kLimit: value.resultLimit === 150 ? undefined : value.resultLimit,
mode: value.mode === "auto" ? undefined : value.mode, mode: value.mode === "auto" ? undefined : value.mode,
}); });
// Trigger immediately so re-submitting the same query (URL unchanged)
// still refetches. The dedup ref prevents the URL effect below from
// double-firing in the typical (URL-changes) case.
triggerSearch({
keyword: value.keyword,
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
});
}, },
); );
// URL-driven search trigger. Fires when the user lands on a shareable URL // The URL is the source of truth for paid keyword research queries. This
// (direct link, cmd+click on a history item, browser back/forward) so the // effect only resets UI state around a new query key; TanStack Query owns the
// page reproduces the search those params describe without a form submit. // actual fetch, cache, dedupe, and error lifecycle.
// When the URL is cleared (no `q`), the page resets to the recent-searches
// empty state so the "Recent searches" Link works without an extra handler.
useEffect(() => { useEffect(() => {
const trimmed = input.keywordInput.trim(); if (activeSearchKey === previousSearchKeyRef.current) return;
previousSearchKeyRef.current = activeSearchKey;
handledSerpSearchKeyRef.current = null;
if (trimmed.length === 0) { if (!activeSearchKey) {
if (lastTriggerKeyRef.current === null) return; clearActiveKeywordResult();
lastTriggerKeyRef.current = null;
resetResearch();
clearSelection();
uiState.setSelectedKeyword(null);
setSerpKeyword(null);
setSerpPage(0);
return; return;
} }
const urlKey = buildKeywordSearchKey({ clearActiveKeywordResult();
keyword: input.keywordInput, }, [activeSearchKey, clearActiveKeywordResult]);
locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
});
if (urlKey === lastTriggerKeyRef.current) return;
triggerSearch({ useEffect(() => {
keyword: input.keywordInput, if (!activeSearchKey || !researchQuery.isSuccess) return;
locationCode, if (handledSerpSearchKeyRef.current === activeSearchKey) return;
resultLimit: input.resultLimit,
mode: input.keywordMode, handledSerpSearchKeyRef.current = activeSearchKey;
}); setSerpKeyword(rows.length > 0 ? searchedKeyword : null);
setSerpPage(0);
}, [ }, [
clearSelection, activeSearchKey,
input.keywordInput, researchQuery.isSuccess,
input.keywordMode, rows.length,
input.resultLimit, searchedKeyword,
locationCode,
resetResearch,
setSerpKeyword, setSerpKeyword,
setSerpPage, setSerpPage,
triggerSearch,
uiState,
]); ]);
const { filteredRows, activeFilterCount } = useKeywordFiltering({ const { filteredRows, activeFilterCount } = useKeywordFiltering({
@ -349,7 +305,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
return { return {
activeFilterCount, activeFilterCount,
activeSerpKeyword, activeSerpKeyword,
beginSearch,
clearSelection, clearSelection,
controlsForm, controlsForm,
filteredRows, filteredRows,
@ -366,10 +321,9 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
mobileTab: uiState.mobileTab, mobileTab: uiState.mobileTab,
overviewKeyword, overviewKeyword,
removeHistoryItem, removeHistoryItem,
resetResearch,
researchError, researchError,
researchMutationError, researchMutationError,
runSearch, retryResearch,
resetFilters, resetFilters,
rows, rows,
searchedKeyword, searchedKeyword,

View File

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Link, useLocation } from "@tanstack/react-router"; import { Link, useLocation } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { import {
ChevronDown, ChevronDown,
ChevronsUpDown, ChevronsUpDown,
@ -35,47 +36,40 @@ export function AuthenticatedAppLayout({
const location = useLocation(); const location = useLocation();
const [drawerOpen, setDrawerOpen] = React.useState(false); const [drawerOpen, setDrawerOpen] = React.useState(false);
const setupModalRef = React.useRef<HTMLDivElement | null>(null); const setupModalRef = React.useRef<HTMLDivElement | null>(null);
const [isSeoApiKeyConfigured, setIsSeoApiKeyConfigured] = React.useState<
boolean | null
>(null);
const [seoApiKeyStatusError, setSeoApiKeyStatusError] = React.useState(false);
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] = const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
React.useState(false); React.useState(false);
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
const seoApiKeyStatusQuery = useQuery({
queryKey: ["seoApiKeyStatus"],
queryFn: () => getSeoApiKeyStatus(),
enabled: shouldCheckSeoApiKeyStatus,
});
const isSeoApiKeyConfigured = shouldCheckSeoApiKeyStatus
? (seoApiKeyStatusQuery.data?.configured ?? null)
: null;
const seoApiKeyStatusError =
shouldCheckSeoApiKeyStatus && seoApiKeyStatusQuery.isError;
React.useEffect(() => { React.useEffect(() => {
if (location.pathname === BILLING_ROUTE) { if (!shouldCheckSeoApiKeyStatus) {
setSeoApiKeyStatusError(false);
setIsSeoApiKeyConfigured(null);
setShowMissingSeoApiKeyModal(false); setShowMissingSeoApiKeyModal(false);
return; return;
} }
let cancelled = false; if (seoApiKeyStatusQuery.isError) {
setShowMissingSeoApiKeyModal(false);
return;
}
const checkSeoApiKeyStatus = async () => { if (!seoApiKeyStatusQuery.isSuccess) return;
try { setShowMissingSeoApiKeyModal(!seoApiKeyStatusQuery.data.configured);
const result = await getSeoApiKeyStatus(); }, [
if (cancelled) return; location.pathname,
seoApiKeyStatusQuery.data,
setSeoApiKeyStatusError(false); seoApiKeyStatusQuery.isError,
setIsSeoApiKeyConfigured(result.configured); seoApiKeyStatusQuery.isSuccess,
if (!result.configured) { shouldCheckSeoApiKeyStatus,
setShowMissingSeoApiKeyModal(true); ]);
}
} catch {
if (cancelled) return;
setSeoApiKeyStatusError(true);
setIsSeoApiKeyConfigured(null);
setShowMissingSeoApiKeyModal(false);
}
};
void checkSeoApiKeyStatus();
return () => {
cancelled = true;
};
}, [location.pathname]);
const shouldShowMissingSeoApiKeyModal = const shouldShowMissingSeoApiKeyModal =
showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH; showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH;