From 703e654a37bcfe1d83d318786039733e696c9546 Mon Sep 17 00:00:00 2001 From: Utku Ufuk Date: Thu, 16 Jul 2026 15:23:30 +0300 Subject: [PATCH] Split the default-scope site search into two parallel API requests (#4395) --- .changeset/split-site-search-requests.md | 5 + .../Search/reciprocalRankFusion.test.ts | 4 +- .../components/Search/reciprocalRankFusion.ts | 2 +- .../src/components/Search/search-types.ts | 7 +- .../src/components/Search/useSearchResults.ts | 203 +++++++++++++----- packages/gitbook/src/lib/data/api.ts | 13 +- packages/gitbook/src/lib/data/types.ts | 7 +- 7 files changed, 187 insertions(+), 54 deletions(-) create mode 100644 .changeset/split-site-search-requests.md diff --git a/.changeset/split-site-search-requests.md b/.changeset/split-site-search-requests.md new file mode 100644 index 000000000..53238f16f --- /dev/null +++ b/.changeset/split-site-search-requests.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted. diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts index 3beb39767..00e61ffb1 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts @@ -14,7 +14,7 @@ function localPage(id: string, title = id): LocalPageResult { }; } -function remotePage(id: string, title = id): OrderedComputedResult { +function remotePage(id: string, title = id, score = 0): OrderedComputedResult { return { type: 'page', id: `remote-${id}`, @@ -22,7 +22,7 @@ function remotePage(id: string, title = id): OrderedComputedResult { spaceId: 'space', title, href: `/${id}`, - score: 0, + score, breadcrumbs: [{ label: 'Remote' }], }; } diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index a610627ce..1ba9fab7c 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -133,7 +133,7 @@ function mergePinnedRemoteResult( /** * Merge local (FlexSearch) and remote (API) search results using - * Reciprocal Rank Fusion (RRF), while preserving the API order for the first + * Reciprocal Rank Fusion (RRF), while preserving the order of the first * three remote results. * * RRF formula: score(d) = Σ_i 1 / (k + rank_i(d)) diff --git a/packages/gitbook/src/components/Search/search-types.ts b/packages/gitbook/src/components/Search/search-types.ts index 32af60a7d..b1751edcf 100644 --- a/packages/gitbook/src/components/Search/search-types.ts +++ b/packages/gitbook/src/components/Search/search-types.ts @@ -37,7 +37,12 @@ export type ComputedRecordResult = BaseComputedResult & { export type SearchSiteContentScope = | { mode: 'all' } - | { mode: 'current'; siteSpaceId: string } + | { + mode: 'current'; + siteSpaceId: string; + /** Restrict the search to the current site space alone, or to the other site spaces in the scope. */ + restrictTo?: 'currentSiteSpace' | 'otherSiteSpaces'; + } | { mode: 'specific'; siteSpaceIds: string[] }; export interface SearchSiteContentRequest { diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index dace56f30..f98f590a6 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -8,7 +8,7 @@ import { createRecommendedQuestionResult, getEmptySearchResults, } from './empty-search-results'; -import type { OrderedComputedResult } from './search-types'; +import type { OrderedComputedResult, SearchSiteContentScope } from './search-types'; import { streamRecommendedQuestions } from './server-actions'; import { useAI } from '@/components/AI'; @@ -28,6 +28,9 @@ export type ResultType = export type { LocalPageResult, MergedPageResult }; +// Score multiplier for current site space results when combined with those from other site spaces +const CURRENT_SITE_SPACE_SCORE_MULTIPLIER = 2; + // Small helper extracted for unit testing of scope → local filter mapping // computeFilterSiteSpaceIds is imported from './filter' for testability @@ -87,9 +90,10 @@ export function useSearchResults(props: { const [remoteState, setRemoteState] = React.useState<{ results: OrderedComputedResult[]; + otherSpacesResults: OrderedComputedResult[]; fetching: boolean; error: boolean; - }>({ results: [], fetching: false, error: false }); + }>({ results: [], otherSpacesResults: [], fetching: false, error: false }); // Track the current in-flight fetch so it can be aborted imperatively // when the user navigates away before the request completes. @@ -105,7 +109,12 @@ export function useSearchResults(props: { } if (!query) { if (!withAI) { - setRemoteState({ results: [], fetching: false, error: false }); + setRemoteState({ + results: [], + otherSpacesResults: [], + fetching: false, + error: false, + }); return; } @@ -116,11 +125,21 @@ export function useSearchResults(props: { `Cached recommended questions should be set for site-space ${siteSpaceId}` ); // Recommended questions are stored as ResultType[] already - setRemoteState({ results: [], fetching: false, error: false }); + setRemoteState({ + results: [], + otherSpacesResults: [], + fetching: false, + error: false, + }); return; } - setRemoteState({ results: [], fetching: false, error: false }); + setRemoteState({ + results: [], + otherSpacesResults: [], + fetching: false, + error: false, + }); let cancelled = false; @@ -133,7 +152,12 @@ export function useSearchResults(props: { suggestions.forEach((question) => { questions.add(question); }); - setRemoteState({ results: [], fetching: false, error: false }); + setRemoteState({ + results: [], + otherSpacesResults: [], + fetching: false, + error: false, + }); return; } @@ -159,7 +183,12 @@ export function useSearchResults(props: { if (!cancelled) { // Recommended questions are handled via a separate path below - setRemoteState({ results: [], fetching: false, error: false }); + setRemoteState({ + results: [], + otherSpacesResults: [], + fetching: false, + error: false, + }); } } }, 100); @@ -171,67 +200,121 @@ export function useSearchResults(props: { } setRemoteState({ results: [], + otherSpacesResults: [], fetching: true, error: false, }); let cancelled = false; const abortController = new AbortController(); const timeout = setTimeout(async () => { - try { - const results = await (() => { - const fetchSearch = ( - scope: Parameters[1] - ): Promise => - fetchSearchResults( - searchURL, - scope, - query, - abortController.signal, - asEmbeddable - ); + const fetchSearch = ( + scope: Parameters[1] + ): Promise => + fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable); + try { + // Each scope resolves to a primary search request and, for the default scope + // on a multi-section site, a secondary request for the other site spaces + const { resultsPromise, otherSpacesResultsPromise } = ((): { + resultsPromise: Promise; + otherSpacesResultsPromise?: Promise; + } => { switch (scope) { case 'all': // Search all content on the site - return fetchSearch({ mode: 'all' }); + return { resultsPromise: fetchSearch({ mode: 'all' }) }; case 'default': - // Search the current section's variant + matched/default variant for other sections - return fetchSearch({ mode: 'current', siteSpaceId }); + // Search the current section's variant + matched/default variant for other sections. + // Without sections, the scope resolves to the current site space alone, so a + // second request restricted to the other site spaces would be redundant. + if (!withSections) { + return { + resultsPromise: fetchSearch({ mode: 'current', siteSpaceId }), + }; + } + + // Split into two parallel requests so the (smaller, faster) current site + // space results can be shown while the other site spaces are still being searched. + return { + resultsPromise: fetchSearch({ + mode: 'current', + siteSpaceId, + restrictTo: 'currentSiteSpace', + }), + otherSpacesResultsPromise: fetchSearch({ + mode: 'current', + siteSpaceId, + restrictTo: 'otherSiteSpaces', + }), + }; case 'extended': // Search all variants of the current section - return fetchSearch({ mode: 'specific', siteSpaceIds }); + return { + resultsPromise: fetchSearch({ mode: 'specific', siteSpaceIds }), + }; case 'current': // Search only the current section's current variant - return fetchSearch({ mode: 'specific', siteSpaceIds: [siteSpaceId] }); + return { + resultsPromise: fetchSearch({ + mode: 'specific', + siteSpaceIds: [siteSpaceId], + }), + }; default: assertNever(scope); } })(); + // Render each result set as soon as its response arrives; a failed + // request reports an error without discarding the other result set. + let tracked = false; + const onResults = + (key: 'results' | 'otherSpacesResults') => + (results: OrderedComputedResult[]) => { + if (cancelled) { + return; + } + + if (!results) { + // Can happen when the route cannot be found and returns the page's html. + setRemoteState((prev) => ({ ...prev, error: true })); + return; + } + + setRemoteState((prev) => ({ ...prev, [key]: results })); + + if (!tracked) { + tracked = true; + trackEvent({ type: 'search_type_query', query }); + } + }; + const onError = () => { + if (cancelled) { + return; + } + setRemoteState((prev) => ({ ...prev, error: true })); + }; + + await Promise.all([ + resultsPromise.then(onResults('results'), onError), + otherSpacesResultsPromise?.then(onResults('otherSpacesResults'), onError), + ]); + if (cancelled) { return; } - - if (!results) { - // One time when this one returns undefined is when it cannot find the server action and returns the html from the page. - // In that case, we want to avoid being stuck in a loading state, but it is an error. - // We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience. - setRemoteState({ results: [], fetching: false, error: true }); - return; - } - - setRemoteState({ results, fetching: false, error: false }); - - trackEvent({ - type: 'search_type_query', - query, - }); + setRemoteState((prev) => ({ ...prev, fetching: false })); } catch { // If there is an error, we need to catch it to avoid infinite loading state. if (cancelled) { return; } - setRemoteState({ results: [], fetching: false, error: true }); + setRemoteState({ + results: [], + otherSpacesResults: [], + fetching: false, + error: true, + }); } }, 200); @@ -258,6 +341,7 @@ export function useSearchResults(props: { suggestions, searchURL, asEmbeddable, + withSections, ]); const abort = React.useCallback(() => { @@ -284,10 +368,23 @@ export function useSearchResults(props: { }); } - const merged = reciprocalRankFusion(localResults, remoteState.results, query); - - return merged; - }, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]); + return reciprocalRankFusion( + localResults, + remoteState.otherSpacesResults.length > 0 + ? combineRemoteResults(remoteState.results, remoteState.otherSpacesResults) + : remoteState.results, + query + ); + }, [ + localResults, + remoteState.results, + remoteState.otherSpacesResults, + query, + withAI, + siteSpaceId, + suggestions, + recentQueries, + ]); return { results, @@ -302,10 +399,7 @@ export function useSearchResults(props: { */ async function fetchSearchResults( searchURL: string, - scope: - | { mode: 'all' } - | { mode: 'current'; siteSpaceId: string } - | { mode: 'specific'; siteSpaceIds: string[] }, + scope: SearchSiteContentScope, query: string, signal?: AbortSignal, asEmbeddable?: boolean @@ -327,3 +421,16 @@ async function fetchSearchResults( return response.json() as Promise; } + +function combineRemoteResults( + remoteResultsCurrentSpace: OrderedComputedResult[], + remoteResultsOtherSpaces: OrderedComputedResult[] +): OrderedComputedResult[] { + return [ + ...remoteResultsCurrentSpace.map((result) => ({ + ...result, + score: result.score * CURRENT_SITE_SPACE_SCORE_MULTIPLIER, + })), + ...remoteResultsOtherSpaces, + ].sort((a, b) => b.score - a.score); +} diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts index fa5704904..36276df24 100644 --- a/packages/gitbook/src/lib/data/api.ts +++ b/packages/gitbook/src/lib/data/api.ts @@ -5,6 +5,7 @@ import { GitBookAPI, type HttpResponse, type RenderIntegrationUI, + type SiteSearchScope, } from '@gitbook/api'; import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; import { parse as parseCacheControl } from '@tusbar/cache-control'; @@ -785,7 +786,17 @@ const searchSiteContent = cache( siteId, { query, - ...scope, + ...(scope.mode === 'current' && scope.restrictTo + ? { + // `restrictTo` only exists in the newer `scope` request shape, + // and the published @gitbook/api types don't include it yet. + scope: { + mode: 'default', + currentSiteSpace: scope.siteSpaceId, + restrictTo: scope.restrictTo, + } as SiteSearchScope, + } + : scope), }, {}, { diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts index 878479f98..94fd768e2 100644 --- a/packages/gitbook/src/lib/data/types.ts +++ b/packages/gitbook/src/lib/data/types.ts @@ -178,7 +178,12 @@ export interface GitBookDataFetcher { query: string; scope: | { mode: 'all' } - | { mode: 'current'; siteSpaceId: string } + | { + mode: 'current'; + siteSpaceId: string; + /** Restrict the search to the current site space alone, or to the other site spaces in the scope. */ + restrictTo?: 'currentSiteSpace' | 'otherSiteSpaces'; + } | { mode: 'specific'; siteSpaceIds: string[] }; /** Cache bust to ensure the search results are fresh when the space is updated. */ cacheBust?: string;