Share search tab queueing across Backlinks, Domain Overview, and Keyword Research (#207)

This commit is contained in:
Ben Senescu 2026-05-20 18:33:53 -04:00 committed by GitHub
parent 01ecf11415
commit aebc779b93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 178 additions and 163 deletions

View File

@ -84,7 +84,6 @@ export function BacklinksPage({
}), }),
[], [],
); );
return ( return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8"> <div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4"> <div className="mx-auto max-w-7xl space-y-4">
@ -102,11 +101,6 @@ export function BacklinksPage({
<BacklinksSearchCard <BacklinksSearchCard
errorMessage={overviewErrorMessage} errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues} initialValues={searchCardInitialValues}
isFetching={
overviewQuery.isFetching ||
referringDomainsQuery.isFetching ||
topPagesQuery.isFetching
}
canOpenSearch={(values) => canOpenSearch={(values) =>
searchTabs.canOpenTab(toBacklinksTabInput(values)) searchTabs.canOpenTab(toBacklinksTabInput(values))
} }
@ -147,6 +141,7 @@ export function BacklinksPage({
tabs: searchTabs.tabs, tabs: searchTabs.tabs,
onSelect: searchTabs.selectTab, onSelect: searchTabs.selectTab,
onClose: searchTabs.closeTab, onClose: searchTabs.closeTab,
onViewed: searchTabs.markTabViewed,
} }
: null : null
} }

View File

@ -52,6 +52,7 @@ type BacklinksBodyProps = {
tabs: SearchTab[]; tabs: SearchTab[];
onSelect: (tab: SearchTab) => void; onSelect: (tab: SearchTab) => void;
onClose: (tabId: string) => void; onClose: (tabId: string) => void;
onViewed: (tabId: string, when?: number) => void;
} | null; } | null;
}; };
@ -105,10 +106,12 @@ export function BacklinksBody({
); );
const tabStrip = searchTabs ? ( const tabStrip = searchTabs ? (
<SearchTabStrip <SearchTabStrip
projectId={projectId}
activeTabId={searchTabs.activeTabId} activeTabId={searchTabs.activeTabId}
tabs={searchTabs.tabs} tabs={searchTabs.tabs}
onSelect={searchTabs.onSelect} onSelect={searchTabs.onSelect}
onClose={searchTabs.onClose} onClose={searchTabs.onClose}
onViewed={searchTabs.onViewed}
/> />
) : null; ) : null;

View File

@ -50,14 +50,12 @@ export function BacklinksSearchCard({
canOpenSearch, canOpenSearch,
errorMessage, errorMessage,
initialValues, initialValues,
isFetching,
onSubmit, onSubmit,
tabLimit, tabLimit,
}: { }: {
canOpenSearch?: (values: SearchDraft) => boolean; canOpenSearch?: (values: SearchDraft) => boolean;
errorMessage: string | null; errorMessage: string | null;
initialValues: SearchDraft; initialValues: SearchDraft;
isFetching: boolean;
onSubmit: (values: SearchDraft) => void; onSubmit: (values: SearchDraft) => void;
tabLimit?: number; tabLimit?: number;
}) { }) {
@ -145,9 +143,9 @@ export function BacklinksSearchCard({
<button <button
type="submit" type="submit"
className="btn btn-primary lg:col-span-2" className="btn btn-primary lg:col-span-2"
disabled={isFetching || isSubmitting} disabled={isSubmitting}
> >
{isFetching || isSubmitting ? "Loading..." : "Search"} {isSubmitting ? "Loading..." : "Search"}
</button> </button>
)} )}
</form.Subscribe> </form.Subscribe>

View File

@ -522,10 +522,12 @@ export function DomainOverviewPage({
</button> </button>
</div> </div>
<SearchTabStrip <SearchTabStrip
projectId={projectId}
activeTabId={searchTabs.activeTabId} activeTabId={searchTabs.activeTabId}
tabs={searchTabs.tabs} tabs={searchTabs.tabs}
onSelect={searchTabs.selectTab} onSelect={searchTabs.selectTab}
onClose={searchTabs.closeTab} onClose={searchTabs.closeTab}
onViewed={searchTabs.markTabViewed}
/> />
</div> </div>
) : null; ) : null;

View File

@ -1,15 +1,8 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, 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";
import {
KEYWORD_RESEARCH_STALE_TIME_MS,
buildKeywordResearchQueryKey,
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController"; import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm"; import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
@ -20,12 +13,12 @@ import type {
KeywordSearchTabInput, KeywordSearchTabInput,
SearchTab, SearchTab,
} from "@/client/features/search-tabs/types"; } from "@/client/features/search-tabs/types";
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState"; import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults"; import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar"; import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">; type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
@ -188,50 +181,6 @@ export function KeywordResearchPage(input: Props) {
})); }));
}, [controller.controlsForm, searchTabs.tabs]); }, [controller.controlsForm, searchTabs.tabs]);
// Mark the active tab as viewed once its data lands. Reads cache state via
// the same query key the controller uses, so this catches both fresh fetches
// and warm-cache loads on tab switch.
const activeRequest = useMemo(
() =>
activeTab
? buildKeywordResearchRequest({
projectId,
keywordInput: activeTab.input.keyword,
locationCode: activeTab.input.locationCode,
resultLimit: activeTab.input.resultLimit,
mode: activeTab.input.mode,
})
: null,
[activeTab, projectId],
);
const activeTabQuery = useQuery({
queryKey: buildKeywordResearchQueryKey(activeRequest),
queryFn: () => {
if (!activeRequest) throw new Error("Active tab missing request");
return keywordResearchQueryFn(activeRequest);
},
enabled: false,
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
});
const markTabViewed = searchTabs.markTabViewed;
useEffect(() => {
if (!activeTab) return;
if (!activeTabQuery.isSuccess) return;
const dataUpdatedAt = activeTabQuery.dataUpdatedAt;
if (dataUpdatedAt <= 0) return;
if (activeTab.viewedAt !== null && activeTab.viewedAt >= dataUpdatedAt) {
return;
}
markTabViewed(activeTab.id, dataUpdatedAt);
}, [
activeTab,
activeTabQuery.dataUpdatedAt,
activeTabQuery.isSuccess,
markTabViewed,
]);
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">
<div className="mx-auto flex max-w-7xl flex-col gap-5"> <div className="mx-auto flex max-w-7xl flex-col gap-5">
@ -254,12 +203,13 @@ export function KeywordResearchPage(input: Props) {
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Recent searches Recent searches
</button> </button>
<KeywordResearchTabStrip <SearchTabStrip
projectId={projectId} projectId={projectId}
tabs={searchTabs.tabs} tabs={searchTabs.tabs}
activeTabId={searchTabs.activeTabId} activeTabId={searchTabs.activeTabId}
onSelect={searchTabs.selectTab} onSelect={searchTabs.selectTab}
onClose={searchTabs.closeTab} onClose={searchTabs.closeTab}
onViewed={searchTabs.markTabViewed}
/> />
</div> </div>
) : null} ) : null}

View File

@ -1,95 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { memo } from "react";
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
import type { SearchTab } from "@/client/features/search-tabs/types";
import {
KEYWORD_RESEARCH_STALE_TIME_MS,
buildKeywordResearchQueryKey,
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
type Props = {
projectId: string;
tabs: SearchTab[];
activeTabId: string | null;
onSelect: (tab: SearchTab) => void;
onClose: (tabId: string) => void;
};
export function KeywordResearchTabStrip({
projectId,
tabs,
activeTabId,
onSelect,
onClose,
}: Props) {
if (tabs.length === 0) return null;
return (
<SearchTabStrip
activeTabId={activeTabId}
tabs={tabs}
onSelect={onSelect}
onClose={onClose}
renderLeading={(tab, active) => (
<KeywordTabStatus tab={tab} projectId={projectId} active={active} />
)}
/>
);
}
const KeywordTabStatus = memo(function KeywordTabStatus({
tab,
projectId,
active,
}: {
tab: SearchTab;
projectId: string;
active: boolean;
}) {
if (tab.input.type !== "keyword") return null;
const request = buildKeywordResearchRequest({
projectId,
keywordInput: tab.input.keyword,
locationCode: tab.input.locationCode,
resultLimit: tab.input.resultLimit,
mode: tab.input.mode,
});
const queryKey = buildKeywordResearchQueryKey(request);
// enabled: false — observer only. The active tab's controller owns fetching.
const query = useQuery({
queryKey,
queryFn: () => {
if (!request) throw new Error("Tab is missing a research request");
return keywordResearchQueryFn(request);
},
enabled: false,
select: () => null,
notifyOnChangeProps: ["dataUpdatedAt", "errorUpdatedAt"],
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
});
const hasResult = query.dataUpdatedAt > 0;
const unviewed =
!active &&
hasResult &&
(tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt);
const isError = query.isError;
return (
<span
className="flex w-3.5 shrink-0 items-center justify-center"
aria-hidden
>
{isError ? (
<span className="size-2 rounded-full bg-error" />
) : unviewed ? (
<span className="size-2 rounded-full bg-primary" />
) : null}
</span>
);
});

View File

@ -1,22 +1,42 @@
import { X } from "lucide-react"; import { useEffect } from "react";
import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query";
import type { QueryKey } from "@tanstack/react-query";
import { Loader2, X } from "lucide-react";
import type { SearchTab } from "./types"; import type { SearchTab } from "./types";
import {
KEYWORD_RESEARCH_STALE_TIME_MS,
buildKeywordResearchQueryKey,
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
import { getLanguageCode } from "@/client/features/keywords/locations";
import { getBacklinksOverview } from "@/serverFunctions/backlinks";
import { getDomainOverview } from "@/serverFunctions/domain";
export type { SearchTab } from "./types"; export type { SearchTab } from "./types";
type Props = { type Props = {
activeTabId: string | null; activeTabId: string | null;
projectId: string;
tabs: SearchTab[]; tabs: SearchTab[];
onSelect: (tab: SearchTab) => void; onSelect: (tab: SearchTab) => void;
onClose: (tabId: string) => void; onClose: (tabId: string) => void;
renderLeading?: (tab: SearchTab, active: boolean) => ReactNode; onViewed: (tabId: string, when?: number) => void;
};
type SearchTabQueryConfig = {
queryKey: QueryKey;
queryFn: () => Promise<unknown>;
staleTime?: number;
gcTime?: number;
}; };
export function SearchTabStrip({ export function SearchTabStrip({
activeTabId, activeTabId,
projectId,
tabs, tabs,
onSelect, onSelect,
onClose, onClose,
renderLeading, onViewed,
}: Props) { }: Props) {
if (tabs.length === 0) return null; if (tabs.length === 0) return null;
@ -46,7 +66,12 @@ export function SearchTabStrip({
className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left" className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left"
onClick={() => onSelect(tab)} onClick={() => onSelect(tab)}
> >
{renderLeading ? renderLeading(tab, active) : null} <SearchTabStatus
tab={tab}
projectId={projectId}
active={active}
onViewed={onViewed}
/>
<span <span
className="max-w-[10rem] truncate font-medium" className="max-w-[10rem] truncate font-medium"
title={tab.label} title={tab.label}
@ -70,3 +95,140 @@ export function SearchTabStrip({
</div> </div>
); );
} }
function SearchTabStatus({
tab,
projectId,
active,
onViewed,
}: {
tab: SearchTab;
projectId: string;
active: boolean;
onViewed: (tabId: string, when?: number) => void;
}) {
const config = getSearchTabQueryConfig(projectId, tab);
const query = useQuery({
queryKey: config.queryKey,
queryFn: config.queryFn,
enabled: false,
select: () => null,
notifyOnChangeProps: ["dataUpdatedAt", "fetchStatus", "status"],
staleTime: config.staleTime,
gcTime: config.gcTime,
});
const isLoading = query.fetchStatus === "fetching";
const hasResult = query.dataUpdatedAt > 0;
const hasError = query.status === "error";
const unviewed =
!active &&
hasResult &&
(tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt);
useEffect(() => {
if (!active) return;
if (!hasResult) return;
if (tab.viewedAt !== null && tab.viewedAt >= query.dataUpdatedAt) return;
onViewed(tab.id, query.dataUpdatedAt);
}, [active, hasResult, onViewed, query.dataUpdatedAt, tab.id, tab.viewedAt]);
const status = isLoading
? "loading"
: hasError
? "error"
: unviewed
? "unviewed"
: "idle";
return <SearchTabStatusIndicator status={status} />;
}
function SearchTabStatusIndicator({
status,
}: {
status: "idle" | "loading" | "unviewed" | "error";
}) {
return (
<span
className="flex w-3.5 shrink-0 items-center justify-center"
aria-hidden
>
{status === "loading" ? (
<Loader2 className="size-3 animate-spin text-base-content/50" />
) : status === "error" ? (
<span className="size-2 rounded-full bg-error" />
) : status === "unviewed" ? (
<span className="size-2 rounded-full bg-primary" />
) : null}
</span>
);
}
function getSearchTabQueryConfig(
projectId: string,
tab: SearchTab,
): SearchTabQueryConfig {
if (tab.input.type === "backlinks") {
const input = tab.input;
return {
queryKey: ["backlinksOverview", projectId, input.scope, input.target],
queryFn: () =>
getBacklinksOverview({
data: {
projectId,
target: input.target,
scope: input.scope,
hideSpam: false,
},
}),
};
}
if (tab.input.type === "domain") {
const input = tab.input;
const trimmedDomain = input.domain.trim();
const languageCode = getLanguageCode(input.locationCode);
return {
queryKey: [
"domain-overview",
projectId,
trimmedDomain,
input.subdomains,
input.locationCode,
languageCode,
],
queryFn: () =>
getDomainOverview({
data: {
projectId,
domain: trimmedDomain,
includeSubdomains: input.subdomains,
locationCode: input.locationCode,
languageCode,
},
}),
staleTime: 5 * 60_000,
};
}
const input = tab.input;
const request = buildKeywordResearchRequest({
projectId,
keywordInput: input.keyword,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.mode,
});
return {
queryKey: buildKeywordResearchQueryKey(request),
queryFn: () => {
if (!request) throw new Error("Tab is missing a research request");
return keywordResearchQueryFn(request);
},
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
};
}