mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-12 06:39:58 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8bb987a7a | |||
| 61852c9a58 | |||
| 0988ca9bb9 | |||
| c1b7012dc2 | |||
| 18abf6f619 | |||
| bfbb8203d1 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Wait for server results before Enter opens a result in site search, and route search prewarm requests on statically served sites.
|
||||
@@ -47,8 +47,8 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
askQuery,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
phase,
|
||||
enterWaiting,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
query,
|
||||
@@ -65,14 +65,16 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
{ restoreLastQueryOnMount: true }
|
||||
);
|
||||
|
||||
const fetching = phase === 'loading';
|
||||
|
||||
return (
|
||||
<LinkContext value={linkContext}>
|
||||
<SearchFrame
|
||||
asEmbeddable={hasDocsTab}
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
phase={phase}
|
||||
enterWaiting={enterWaiting}
|
||||
fillHeight={true}
|
||||
query={query}
|
||||
results={results}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { Emoji } from '../primitives/Emoji/Emoji';
|
||||
import { Link } from '../primitives/Link';
|
||||
import type { LocalPageResult } from './useLocalSearchResults';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Renders local search results above the main server results.
|
||||
*
|
||||
* Layout:
|
||||
* - While server results are still fetching → 2-column wrapping row (cards fill width)
|
||||
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
|
||||
*
|
||||
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
|
||||
*/
|
||||
export function LocalSearchResults({
|
||||
results,
|
||||
fetching,
|
||||
}: {
|
||||
results: LocalPageResult[];
|
||||
fetching: boolean;
|
||||
}) {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'py-2',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-2',
|
||||
fetching
|
||||
? 'flex-wrap'
|
||||
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
|
||||
)}
|
||||
>
|
||||
{results.map((result) => (
|
||||
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalSearchResultCard({
|
||||
result,
|
||||
fetching,
|
||||
}: {
|
||||
result: LocalPageResult;
|
||||
fetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={result.pathname}
|
||||
className={tcls(
|
||||
'group',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-1',
|
||||
'p-3',
|
||||
'rounded-corners:rounded-lg',
|
||||
'circular-corners:rounded-2xl',
|
||||
'text-tint',
|
||||
'bg-tint-subtle',
|
||||
'hover:bg-tint',
|
||||
'hover:text-tint-strong',
|
||||
'transition-colors',
|
||||
'cursor-pointer',
|
||||
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
|
||||
{result.emoji ? (
|
||||
<span className="size-4 shrink-0 text-tint-subtle">
|
||||
<Emoji code={result.emoji} style="text-base leading-none" />
|
||||
</span>
|
||||
) : result.icon ? (
|
||||
<span className="size-4 shrink-0 text-tint-subtle">
|
||||
<Icon icon={result.icon as IconName} className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
|
||||
{result.title}
|
||||
</p>
|
||||
<span className="ml-auto shrink-0 text-tint-subtle">
|
||||
<Icon icon="chevron-right" className="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
{result.description ? (
|
||||
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +50,8 @@ export function SearchContainer({
|
||||
askInAssistant,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
phase,
|
||||
enterWaiting,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
open,
|
||||
@@ -67,6 +67,7 @@ export function SearchContainer({
|
||||
withSearchAI,
|
||||
scopeControl,
|
||||
} = useSearchController(searchProps);
|
||||
const fetching = phase === 'loading';
|
||||
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
|
||||
|
||||
useHotkeys(
|
||||
@@ -168,8 +169,8 @@ export function SearchContainer({
|
||||
<SearchFrame
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
phase={phase}
|
||||
enterWaiting={enterWaiting}
|
||||
input={usesSideSheet ? frameInput : undefined}
|
||||
query={query}
|
||||
results={results}
|
||||
|
||||
@@ -16,14 +16,15 @@ import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { SearchAskBar } from './SearchAskBar';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import type { ResultType } from './useSearchResults';
|
||||
import type { ResultType, SearchPhase } from './useSearchResults';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
export function SearchFrame(props: {
|
||||
asEmbeddable?: boolean;
|
||||
askQuery: string;
|
||||
cursor: number | null;
|
||||
error: boolean;
|
||||
fetching: boolean;
|
||||
phase: SearchPhase;
|
||||
/** Enter was pressed and will act once results have loaded. */
|
||||
enterWaiting: boolean;
|
||||
fillHeight?: boolean;
|
||||
input?: React.ReactNode;
|
||||
query: string;
|
||||
@@ -43,8 +44,8 @@ export function SearchFrame(props: {
|
||||
className,
|
||||
cursor,
|
||||
dataTestId,
|
||||
error,
|
||||
fetching,
|
||||
phase,
|
||||
enterWaiting,
|
||||
fillHeight = false,
|
||||
input,
|
||||
query,
|
||||
@@ -56,6 +57,7 @@ export function SearchFrame(props: {
|
||||
showAsk,
|
||||
sidebar,
|
||||
} = props;
|
||||
const fetching = phase === 'loading';
|
||||
|
||||
const searchAsk = useSearchAskState();
|
||||
const { assistants } = useAI();
|
||||
@@ -77,7 +79,7 @@ export function SearchFrame(props: {
|
||||
fetching ? 'block animate-fade-in' : 'hidden animate-fade-out-slow'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: fetching ? '2s' : undefined,
|
||||
animationDelay: fetching && !enterWaiting ? '2s' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -111,10 +113,9 @@ export function SearchFrame(props: {
|
||||
ref={resultsRef}
|
||||
query={query}
|
||||
id={resultsId}
|
||||
fetching={fetching}
|
||||
phase={phase}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
onResultSelect={onResultSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -133,7 +133,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
}
|
||||
}
|
||||
trailing={
|
||||
!showAsk && value && resultsCount > 0 ? (
|
||||
!showAsk && value && !fetching && resultsCount > 0 ? (
|
||||
<div className="animate-blur-in mr-2 text-sm text-tint-subtle">
|
||||
{t(language, 'search_results_count', resultsCount.toString())}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
|
||||
import { SearchRecordResultItem } from './SearchRecordResultItem';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import type { ResultType } from './useSearchResults';
|
||||
import type { ResultType, SearchPhase } from './useSearchResults';
|
||||
import { useAI } from '@/components/AI';
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
@@ -47,14 +47,15 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
id: string;
|
||||
query: string;
|
||||
results: ResultType[];
|
||||
fetching: boolean;
|
||||
phase: SearchPhase;
|
||||
cursor: number | null;
|
||||
error: boolean;
|
||||
onResultSelect?: () => void;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, id, query, results, fetching, cursor, error, onResultSelect } = props;
|
||||
const { children, id, query, results, phase, cursor, onResultSelect } = props;
|
||||
const fetching = phase === 'loading';
|
||||
const error = phase === 'failed';
|
||||
|
||||
const language = useLanguage();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
const questionWords = new Set([
|
||||
'who',
|
||||
'what',
|
||||
'where',
|
||||
'when',
|
||||
'why',
|
||||
'how',
|
||||
'explain',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'which',
|
||||
'whom',
|
||||
'whose',
|
||||
'can',
|
||||
'have',
|
||||
'give',
|
||||
'tell',
|
||||
'show',
|
||||
'find',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return true if an input query looks like a question.
|
||||
*/
|
||||
export function isQuestion(query: string): boolean {
|
||||
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const words = query.toLowerCase().trim().split(/\s+/);
|
||||
|
||||
if (words.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const word of words) {
|
||||
if (questionWords.has(word)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -15,10 +15,9 @@ import {
|
||||
} from './last-query';
|
||||
import { addRecentSearchQuery } from './recent-queries';
|
||||
import { findSearchSection, type SearchBaseProps } from './search-props';
|
||||
import type { SearchResultsRef } from './SearchResults';
|
||||
import { useSearchState, useSetSearchState } from './useSearch';
|
||||
import { useSearchKeyboardNavigation } from './useSearchKeyboardNavigation';
|
||||
import { useSearchResults } from './useSearchResults';
|
||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||
import { getLocalizedTitle } from '@/lib/sites';
|
||||
|
||||
function useInitialAskBootstrap(props: {
|
||||
@@ -72,51 +71,6 @@ function useFilteredSiteSpaceIds(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function useSearchKeyboardNavigation(props: {
|
||||
query: string;
|
||||
results: ReturnType<typeof useSearchResults>['results'];
|
||||
resultsRef: React.RefObject<SearchResultsRef | null>;
|
||||
abort: () => void;
|
||||
askCount: number;
|
||||
onAskSelect: (index: number) => void;
|
||||
}) {
|
||||
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
|
||||
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
|
||||
query,
|
||||
resultCount: results.length,
|
||||
totalCount: results.length + askCount,
|
||||
});
|
||||
|
||||
const onInputKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveCursorBy(-1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveCursorBy(1);
|
||||
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (cursor !== null && cursor >= results.length) {
|
||||
onAskSelect(cursor - results.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultsRef.current?.select()) {
|
||||
// Stop any in-flight search request only when activating a result.
|
||||
abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
onInputKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearchController(
|
||||
props: SearchBaseProps,
|
||||
options: { restoreLastQueryOnMount?: boolean } = {}
|
||||
@@ -139,7 +93,6 @@ export function useSearchController(
|
||||
const setSearchState = useSetSearchState();
|
||||
const router = useRouter();
|
||||
const trackEvent = useTrackEvent();
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
const isLoaded = useBodyLoaded();
|
||||
const integrationsLoaded = useIntegrationsLoaded();
|
||||
|
||||
@@ -248,7 +201,7 @@ export function useSearchController(
|
||||
});
|
||||
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
|
||||
|
||||
const { results, fetching, error, abort } = useSearchResults({
|
||||
const { results, phase, abort } = useSearchResults({
|
||||
asEmbeddable,
|
||||
disabled: !(normalizedQuery || withAI),
|
||||
open: Boolean(state?.open),
|
||||
@@ -302,11 +255,11 @@ export function useSearchController(
|
||||
|
||||
const askCount = normalizedQuery && !showAsk ? assistants.length : 0;
|
||||
|
||||
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
|
||||
query: normalizedQuery,
|
||||
const { cursor, onInputKeyDown, enterWaiting, resultsRef } = useSearchKeyboardNavigation({
|
||||
searchKey: [searchValue, state?.scope, state?.section].join('\n'),
|
||||
open: Boolean(state?.open),
|
||||
phase,
|
||||
results,
|
||||
resultsRef,
|
||||
abort,
|
||||
askCount,
|
||||
onAskSelect: askInAssistant,
|
||||
});
|
||||
@@ -317,8 +270,8 @@ export function useSearchController(
|
||||
askCount,
|
||||
askInAssistant,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
phase,
|
||||
enterWaiting,
|
||||
abort,
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { SearchResultsRef } from './SearchResults';
|
||||
import type { ResultType, SearchPhase } from './useSearchResults';
|
||||
|
||||
/** The row highlighted when none was picked with the arrow keys. */
|
||||
function getDefaultCursor(phase: SearchPhase, rowCount: number): number | null {
|
||||
// Results listed while loading are provisional, so none of them is the default.
|
||||
return phase === 'loading' || rowCount === 0 ? null : 0;
|
||||
}
|
||||
|
||||
type EnterAction =
|
||||
| { type: 'wait' }
|
||||
| { type: 'open' }
|
||||
| { type: 'ask'; assistantIndex: number }
|
||||
| { type: 'none' };
|
||||
|
||||
/** What Enter does, given the highlighted row. */
|
||||
function getEnterAction(input: {
|
||||
phase: SearchPhase;
|
||||
cursor: number | null;
|
||||
resultCount: number;
|
||||
listMounted: boolean;
|
||||
}): EnterAction {
|
||||
const { phase, cursor, resultCount, listMounted } = input;
|
||||
|
||||
if (cursor === null) {
|
||||
return phase === 'loading' ? { type: 'wait' } : { type: 'none' };
|
||||
}
|
||||
if (cursor >= resultCount) {
|
||||
return { type: 'ask', assistantIndex: cursor - resultCount };
|
||||
}
|
||||
// The results list is loaded on demand and can mount after the results arrive.
|
||||
return listMounted ? { type: 'open' } : { type: 'wait' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard navigation of the search rows (results, then one "ask" row per assistant):
|
||||
* which row is highlighted and what Enter does.
|
||||
*/
|
||||
export function useSearchKeyboardNavigation(props: {
|
||||
/** Identifies the search; changes with the input value, the scope and the section. */
|
||||
searchKey: string;
|
||||
open: boolean;
|
||||
phase: SearchPhase;
|
||||
results: ResultType[];
|
||||
askCount: number;
|
||||
onAskSelect: (assistantIndex: number) => void;
|
||||
}) {
|
||||
const { searchKey, open, phase, results, askCount, onAskSelect } = props;
|
||||
const rowCount = results.length + askCount;
|
||||
|
||||
const resultsListRef = React.useRef<SearchResultsRef | null>(null);
|
||||
const [listMounted, setListMounted] = React.useState(false);
|
||||
const setResultsList = React.useCallback((list: SearchResultsRef | null) => {
|
||||
resultsListRef.current = list;
|
||||
setListMounted(list !== null);
|
||||
}, []);
|
||||
|
||||
// A pick only holds for the list it was made on.
|
||||
const [picked, setPicked] = React.useState<{ results: ResultType[]; index: number } | null>(
|
||||
null
|
||||
);
|
||||
let cursor = getDefaultCursor(phase, rowCount);
|
||||
if (picked?.results === results && rowCount > 0) {
|
||||
cursor = Math.min(picked.index, rowCount - 1);
|
||||
}
|
||||
|
||||
const moveCursorBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
if (rowCount === 0) {
|
||||
return;
|
||||
}
|
||||
const index = cursor === null ? 0 : Math.max(Math.min(cursor + delta, rowCount - 1), 0);
|
||||
setPicked({ results, index });
|
||||
},
|
||||
[cursor, results, rowCount]
|
||||
);
|
||||
|
||||
// The search Enter was pressed on while it could not act yet.
|
||||
const [waitingFor, setWaitingFor] = React.useState<string | null>(null);
|
||||
|
||||
const pressEnter = React.useCallback(() => {
|
||||
const action = getEnterAction({
|
||||
phase,
|
||||
cursor,
|
||||
resultCount: results.length,
|
||||
listMounted,
|
||||
});
|
||||
setWaitingFor(action.type === 'wait' ? searchKey : null);
|
||||
|
||||
if (action.type === 'open') {
|
||||
resultsListRef.current?.select();
|
||||
} else if (action.type === 'ask') {
|
||||
onAskSelect(action.assistantIndex);
|
||||
}
|
||||
}, [phase, cursor, results.length, listMounted, searchKey, onAskSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (waitingFor === null) {
|
||||
return;
|
||||
}
|
||||
// Dropped when the search changed, the box closed, or the visitor opened a result
|
||||
// or asked an assistant before the server answered.
|
||||
if (waitingFor !== searchKey || !open || phase === 'stopped') {
|
||||
setWaitingFor(null);
|
||||
return;
|
||||
}
|
||||
pressEnter();
|
||||
}, [waitingFor, searchKey, open, phase, pressEnter]);
|
||||
|
||||
const onInputKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setWaitingFor(null);
|
||||
moveCursorBy(event.key === 'ArrowUp' ? -1 : 1);
|
||||
} else if (event.key === 'Escape') {
|
||||
setWaitingFor(null);
|
||||
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
pressEnter();
|
||||
}
|
||||
},
|
||||
[moveCursorBy, pressEnter]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
onInputKeyDown,
|
||||
enterWaiting: waitingFor !== null,
|
||||
resultsRef: setResultsList,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import {
|
||||
@@ -26,6 +25,35 @@ export type ResultType =
|
||||
|
||||
export type { LocalPageResult, MergedPageResult };
|
||||
|
||||
/**
|
||||
* Where a search stands. Results listed while `loading` are instant title matches only,
|
||||
* so they are provisional; in every other phase the list is final.
|
||||
*/
|
||||
export type SearchPhase = 'idle' | 'loading' | 'loaded' | 'failed' | 'stopped';
|
||||
|
||||
type RemoteSearch =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading'; query: string }
|
||||
| { status: 'loaded'; query: string; results: OrderedComputedResult[] }
|
||||
| { status: 'failed'; query: string }
|
||||
// The visitor opened a result or asked an assistant before the server answered.
|
||||
| { status: 'stopped'; query: string };
|
||||
|
||||
const NO_REMOTE_RESULTS: OrderedComputedResult[] = [];
|
||||
|
||||
const SEARCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function getSearchPhase(query: string, remote: RemoteSearch): SearchPhase {
|
||||
if (!query) {
|
||||
return 'idle';
|
||||
}
|
||||
// A new query renders once before its request starts.
|
||||
if (remote.status === 'idle' || remote.query !== query) {
|
||||
return 'loading';
|
||||
}
|
||||
return remote.status;
|
||||
}
|
||||
|
||||
// Small helper extracted for unit testing of scope → local filter mapping
|
||||
// computeFilterSiteSpaceIds is imported from './filter' for testability
|
||||
|
||||
@@ -96,11 +124,7 @@ export function useSearchResults(props: {
|
||||
filterSiteSpaceIds,
|
||||
});
|
||||
|
||||
const [remoteState, setRemoteState] = React.useState<{
|
||||
results: OrderedComputedResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
}>({ results: [], fetching: false, error: false });
|
||||
const [remote, setRemote] = React.useState<RemoteSearch>({ status: 'idle' });
|
||||
|
||||
// Track the current in-flight fetch so it can be aborted imperatively
|
||||
// when the user navigates away before the request completes.
|
||||
@@ -115,36 +139,15 @@ export function useSearchResults(props: {
|
||||
return;
|
||||
}
|
||||
if (!query) {
|
||||
if (!withAI) {
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
setRemote({ status: 'idle' });
|
||||
|
||||
const hasQuestions =
|
||||
cachedRecommendedQuestions.has(siteSpaceId) ||
|
||||
(suggestions !== undefined && suggestions.length > 0);
|
||||
if (!withAI || hasQuestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedRecommendedQuestions.has(siteSpaceId)) {
|
||||
const results = cachedRecommendedQuestions.get(siteSpaceId);
|
||||
assert(
|
||||
results,
|
||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||
);
|
||||
// Recommended questions are stored as ResultType[] already
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// We currently have a bug where the same question can be returned multiple times.
|
||||
@@ -152,18 +155,6 @@ export function useSearchResults(props: {
|
||||
const questions = new Set<string>();
|
||||
const recommendedQuestions: RecommendedQuestionResult[] = [];
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
suggestions.forEach((question) => {
|
||||
questions.add(question);
|
||||
});
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -185,12 +176,8 @@ export function useSearchResults(props: {
|
||||
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
||||
|
||||
if (!cancelled) {
|
||||
// Recommended questions are handled via a separate path below
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
// A new object re-renders, so the list picks up the cached questions.
|
||||
setRemote({ status: 'idle' });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
@@ -200,11 +187,7 @@ export function useSearchResults(props: {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
setRemote({ status: 'loading', query });
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(async () => {
|
||||
@@ -213,8 +196,10 @@ export function useSearchResults(props: {
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
|
||||
|
||||
// A request that never answers would leave the search loading for good.
|
||||
const giveUp = setTimeout(() => abortController.abort(), SEARCH_TIMEOUT_MS);
|
||||
try {
|
||||
const resultsPromise = fetchSearch(
|
||||
const results = await fetchSearch(
|
||||
computeRemoteSearchScope(
|
||||
scope,
|
||||
siteSpaceId,
|
||||
@@ -222,44 +207,25 @@ export function useSearchResults(props: {
|
||||
selectedSectionSiteSpaceIds
|
||||
)
|
||||
);
|
||||
|
||||
const onResults = (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, results }));
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
};
|
||||
const onError = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
};
|
||||
|
||||
await resultsPromise.then(onResults, onError);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState((prev) => ({ ...prev, fetching: false }));
|
||||
|
||||
if (!results) {
|
||||
// Can happen when the route cannot be found and returns the page's html.
|
||||
setRemote({ status: 'failed', query });
|
||||
return;
|
||||
}
|
||||
|
||||
setRemote({ status: 'loaded', query, results });
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
} 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,
|
||||
});
|
||||
setRemote({ status: 'failed', query });
|
||||
} finally {
|
||||
clearTimeout(giveUp);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
@@ -292,9 +258,15 @@ export function useSearchResults(props: {
|
||||
const abort = React.useCallback(() => {
|
||||
abortRef.current?.();
|
||||
abortRef.current = null;
|
||||
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
|
||||
setRemote((prev) =>
|
||||
prev.status === 'loading' ? { status: 'stopped', query: prev.query } : prev
|
||||
);
|
||||
}, []);
|
||||
|
||||
const phase = getSearchPhase(query, remote);
|
||||
const remoteResults =
|
||||
phase === 'loaded' && remote.status === 'loaded' ? remote.results : NO_REMOTE_RESULTS;
|
||||
|
||||
// Merge local and remote results.
|
||||
// Re-runs immediately whenever either result set changes.
|
||||
const results = React.useMemo<ResultType[]>(() => {
|
||||
@@ -315,13 +287,14 @@ export function useSearchResults(props: {
|
||||
|
||||
return fuseSearchResults({
|
||||
localResults,
|
||||
remoteResults: remoteState.results,
|
||||
remoteResults,
|
||||
query,
|
||||
allowedSiteSpaceIds: filterSiteSpaceIds,
|
||||
});
|
||||
}, [
|
||||
localResults,
|
||||
remoteState.results,
|
||||
remote,
|
||||
remoteResults,
|
||||
query,
|
||||
filterSiteSpaceIds,
|
||||
withAI,
|
||||
@@ -330,12 +303,7 @@ export function useSearchResults(props: {
|
||||
recentQueries,
|
||||
]);
|
||||
|
||||
return {
|
||||
results,
|
||||
fetching: remoteState.fetching,
|
||||
error: remoteState.error,
|
||||
abort,
|
||||
};
|
||||
return { results, phase, abort };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function useSearchResultsCursor(props: {
|
||||
query: string;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const { query, resultCount, totalCount } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (totalCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursor((prev) => {
|
||||
if (resultCount > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return prev ?? 0;
|
||||
});
|
||||
}, [resultCount, totalCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCursor((prev) => {
|
||||
if (prev === null) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(prev, totalCount - 1);
|
||||
});
|
||||
}, [totalCount]);
|
||||
|
||||
const moveBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
setCursor((prev) => {
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prev === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(Math.min(prev + delta, totalCount - 1), 0);
|
||||
});
|
||||
},
|
||||
[totalCount]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
setCursor,
|
||||
moveBy,
|
||||
};
|
||||
}
|
||||
@@ -933,6 +933,7 @@ function encodePathInSiteContent(
|
||||
case AI_CATALOG_PATH:
|
||||
case '~gitbook/pdf':
|
||||
case '~gitbook/search':
|
||||
case '~gitbook/search-prewarm':
|
||||
case '~gitbook/auth/login':
|
||||
case '~gitbook/auth/logout':
|
||||
// PDF, search and auth routes are always dynamic as they depend on the request.
|
||||
|
||||
Reference in New Issue
Block a user