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 (
<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">
@ -102,11 +101,6 @@ export function BacklinksPage({
<BacklinksSearchCard
errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues}
isFetching={
overviewQuery.isFetching ||
referringDomainsQuery.isFetching ||
topPagesQuery.isFetching
}
canOpenSearch={(values) =>
searchTabs.canOpenTab(toBacklinksTabInput(values))
}
@ -147,6 +141,7 @@ export function BacklinksPage({
tabs: searchTabs.tabs,
onSelect: searchTabs.selectTab,
onClose: searchTabs.closeTab,
onViewed: searchTabs.markTabViewed,
}
: null
}

View File

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

View File

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

View File

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

View File

@ -1,15 +1,8 @@
import { Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo } from "react";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { getErrorCode } from "@/client/lib/error-messages";
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 type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
@ -20,12 +13,12 @@ import type {
KeywordSearchTabInput,
SearchTab,
} from "@/client/features/search-tabs/types";
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
import type { KeywordResearchControllerState } from "./types";
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
@ -188,50 +181,6 @@ export function KeywordResearchPage(input: Props) {
}));
}, [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 (
<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">
@ -254,12 +203,13 @@ export function KeywordResearchPage(input: Props) {
<ArrowLeft className="size-4" />
Recent searches
</button>
<KeywordResearchTabStrip
<SearchTabStrip
projectId={projectId}
tabs={searchTabs.tabs}
activeTabId={searchTabs.activeTabId}
onSelect={searchTabs.selectTab}
onClose={searchTabs.closeTab}
onViewed={searchTabs.markTabViewed}
/>
</div>
) : 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 type { ReactNode } from "react";
import { useEffect } 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 {
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";
type Props = {
activeTabId: string | null;
projectId: string;
tabs: SearchTab[];
onSelect: (tab: SearchTab) => 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({
activeTabId,
projectId,
tabs,
onSelect,
onClose,
renderLeading,
onViewed,
}: Props) {
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"
onClick={() => onSelect(tab)}
>
{renderLeading ? renderLeading(tab, active) : null}
<SearchTabStatus
tab={tab}
projectId={projectId}
active={active}
onViewed={onViewed}
/>
<span
className="max-w-[10rem] truncate font-medium"
title={tab.label}
@ -70,3 +95,140 @@ export function SearchTabStrip({
</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,
};
}