Compare commits

...

6 Commits

Author SHA1 Message Date
Peter White c8bb987a7a Send search_open_result for server results only
Instant matches carry no space ID, so they send no event and the site index stays as it is.
2026-11-11 20:48:45 +00:00
Peter White 61852c9a58 Fail the search after 10 seconds without a server answer
The search request had no time limit, so a request that never answered left the box loading. It now aborts after 10 seconds and the search becomes failed: the instant matches are listed with the notice, and a waiting Enter opens the first one.
2026-11-11 20:48:45 +00:00
Peter White 0988ca9bb9 Merge remote-tracking branch 'origin/main' into peter/search-enter-and-prewarm 2026-11-11 20:38:07 +00:00
Peter White c1b7012dc2 changeset 2026-11-11 20:38:07 +00:00
Peter White 18abf6f619 Route search prewarm requests as dynamic
The ~gitbook/search-prewarm route only exists for dynamic sites. Statically routed sites sent the POST to a page, which answered 405.
2026-11-11 20:38:07 +00:00
Peter White bfbb8203d1 Wait for server results before Enter opens a search result (RND-13944)
Site search lists instant title matches while the server results load. The list had a result count and a highlighted first row, so Enter opened the first title match, which is often not the page the server ranks first.

Search tracks one phase: idle, loading, loaded, failed or stopped. A list gets a count, a default highlight and an Enter action only once it is past loading. Enter pressed while loading waits and then opens the server's top result. Arrow keys still pick a row that Enter opens at once.

The keyboard logic lives in useSearchKeyboardNavigation. Opening an instant match sends search_open_result, with the space ID taken from a spaceIds map in the site index. LocalSearchResults.tsx and isQuestion.ts had no importers and are removed.
2026-11-11 20:38:01 +00:00
13 changed files with 239 additions and 385 deletions
@@ -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,
};
}
+1
View File
@@ -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.