mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Simplify search query handling to in-memory storage only (#4419)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep the last search or ask query visible after closing its surface and restore it when reopening search or an embed.
|
||||
@@ -197,8 +197,9 @@ export function EmbeddableIframeTabs(props: {
|
||||
active?: string;
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
}) {
|
||||
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
||||
const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props;
|
||||
const actions = useEmbeddableConfiguration((state) => state.actions);
|
||||
const tabs = useEmbeddableTabs();
|
||||
|
||||
@@ -261,6 +262,10 @@ export function EmbeddableIframeTabs(props: {
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
iconOnly
|
||||
onClick={() => {
|
||||
if (tab.key !== active && onNavigate) {
|
||||
onNavigate(tab.href);
|
||||
return;
|
||||
}
|
||||
router.push(tab.href);
|
||||
}}
|
||||
tooltipProps={{
|
||||
@@ -274,7 +279,8 @@ export function EmbeddableIframeTabs(props: {
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function EmbeddableIframeCloseButton() {
|
||||
export function EmbeddableIframeCloseButton(props: { onClose?: () => void }) {
|
||||
const { onClose } = props;
|
||||
const { closeButton } = useEmbeddableConfiguration();
|
||||
|
||||
if (!closeButton) {
|
||||
@@ -291,6 +297,7 @@ export function EmbeddableIframeCloseButton() {
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
iconOnly
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
getChannel()?.send({ type: 'close' });
|
||||
}}
|
||||
tooltipProps={{
|
||||
|
||||
@@ -38,10 +38,13 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
askQuery,
|
||||
abort,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
query,
|
||||
results,
|
||||
resultsId,
|
||||
@@ -51,7 +54,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
showAsk,
|
||||
withSearchAI,
|
||||
scopeControl,
|
||||
} = useSearchController({ ...searchProps, asEmbeddable: hasDocsTab });
|
||||
} = useSearchController(
|
||||
{ ...searchProps, asEmbeddable: hasDocsTab },
|
||||
{ restoreLastQueryOnMount: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkContext value={linkContext}>
|
||||
@@ -66,6 +72,8 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
onAskSelect={abort}
|
||||
onResultSelect={onResultSelect}
|
||||
showAsk={showAsk}
|
||||
dataTestId="embed-search"
|
||||
input={
|
||||
@@ -97,9 +105,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
active="search"
|
||||
baseURL={baseURL}
|
||||
siteTitle={siteTitle}
|
||||
onNavigate={close}
|
||||
/>
|
||||
<EmbeddableIframeButtons />
|
||||
<EmbeddableIframeCloseButton />
|
||||
<EmbeddableIframeCloseButton onClose={close} />
|
||||
</>
|
||||
}
|
||||
scopeControl={
|
||||
|
||||
@@ -50,6 +50,7 @@ export function SearchContainer({
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
open,
|
||||
query,
|
||||
results,
|
||||
@@ -169,7 +170,8 @@ export function SearchContainer({
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
onResultSelect={abort}
|
||||
onAskSelect={abort}
|
||||
onResultSelect={onResultSelect}
|
||||
showAsk={showAsk}
|
||||
scopeControl={scopeControlNode}
|
||||
fillHeight={usesSideSheet || shouldFillHeight}
|
||||
|
||||
@@ -29,7 +29,8 @@ export function SearchFrame(props: {
|
||||
results: ResultType[];
|
||||
resultsId: string;
|
||||
resultsRef: React.Ref<SearchResultsRef>;
|
||||
onResultSelect?: () => void;
|
||||
onAskSelect?: () => void;
|
||||
onResultSelect?: (result: ResultType) => void;
|
||||
scopeControl?: React.ReactNode;
|
||||
showAsk: boolean;
|
||||
sidebar?: React.ReactNode;
|
||||
@@ -50,6 +51,7 @@ export function SearchFrame(props: {
|
||||
results,
|
||||
resultsId,
|
||||
resultsRef,
|
||||
onAskSelect,
|
||||
onResultSelect,
|
||||
scopeControl,
|
||||
showAsk,
|
||||
@@ -126,7 +128,7 @@ export function SearchFrame(props: {
|
||||
assistant={assistant}
|
||||
active={cursor === results.length + index}
|
||||
withShortcut={assistant === assistants[0]}
|
||||
onSelect={onResultSelect}
|
||||
onSelect={onAskSelect}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
fetching: boolean;
|
||||
cursor: number | null;
|
||||
error: boolean;
|
||||
onResultSelect?: () => void;
|
||||
onResultSelect?: (result: ResultType) => void;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
@@ -211,7 +211,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
addRecentSearchQuery(siteSpaceId, query, 'search');
|
||||
}
|
||||
|
||||
onResultSelect?.();
|
||||
onResultSelect?.(item);
|
||||
};
|
||||
const resultItemProps = {
|
||||
'aria-posinset': index + 1,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
type LastSearchQueryBySiteSpace = Record<string, string>;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let globalLastSearchQuery: LastSearchQueryBySiteSpace = {};
|
||||
|
||||
function emitChange() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function getLastSearchQuery(siteSpaceId: string): string | null {
|
||||
return globalLastSearchQuery[siteSpaceId] ?? null;
|
||||
}
|
||||
|
||||
export function setLastSearchQuery(siteSpaceId: string, query: string | null): void {
|
||||
if (!siteSpaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = query?.trim() || null;
|
||||
const nextState = { ...globalLastSearchQuery };
|
||||
|
||||
if (normalizedQuery) {
|
||||
nextState[siteSpaceId] = normalizedQuery;
|
||||
} else {
|
||||
delete nextState[siteSpaceId];
|
||||
}
|
||||
|
||||
globalLastSearchQuery = nextState;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
export function clearLastSearchQuery(siteSpaceId: string): void {
|
||||
setLastSearchQuery(siteSpaceId, null);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useLastSearchQuery(siteSpaceId: string): string | null {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => globalLastSearchQuery[siteSpaceId] ?? null,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,12 @@ function normalizeRawState(values: Values<typeof keyMap>) {
|
||||
return values;
|
||||
}
|
||||
|
||||
export function shouldKeepSearchState(
|
||||
values: Pick<Values<typeof keyMap>, 'q' | 'ask' | 'scope'>
|
||||
): boolean {
|
||||
return values.q !== null || values.ask !== null || values.scope !== 'default';
|
||||
}
|
||||
|
||||
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
|
||||
const { children } = props;
|
||||
|
||||
@@ -74,7 +80,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
|
||||
const state = React.useMemo<SearchState | null>(() => {
|
||||
const normalized = normalizeRawState(rawState);
|
||||
if (normalized.q === null && normalized.ask === null) {
|
||||
if (!shouldKeepSearchState(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -7,10 +7,16 @@ import { useAI } from '../AI';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useBodyLoaded } from '../primitives';
|
||||
import type { SearchResultsRef } from './SearchResults';
|
||||
import {
|
||||
clearLastSearchQuery,
|
||||
getLastSearchQuery,
|
||||
setLastSearchQuery,
|
||||
useLastSearchQuery,
|
||||
} from './last-query';
|
||||
import { addRecentSearchQuery } from './recent-queries';
|
||||
import type { SearchBaseProps } from './search-props';
|
||||
import { useSearchState, useSetSearchState } from './useSearch';
|
||||
import { useSearchResults } from './useSearchResults';
|
||||
import { type ResultType, useSearchResults } from './useSearchResults';
|
||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||
|
||||
function useInitialAskBootstrap(props: {
|
||||
@@ -109,7 +115,10 @@ function useSearchKeyboardNavigation(props: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearchController(props: SearchBaseProps) {
|
||||
export function useSearchController(
|
||||
props: SearchBaseProps,
|
||||
options: { restoreLastQueryOnMount?: boolean } = {}
|
||||
) {
|
||||
const {
|
||||
asEmbeddable,
|
||||
siteSpace,
|
||||
@@ -130,6 +139,32 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
const isLoaded = useBodyLoaded();
|
||||
|
||||
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!options.restoreLastQueryOnMount ||
|
||||
restoredLastQueryForSiteSpaceRef.current === siteSpace.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredLastQueryForSiteSpaceRef.current = siteSpace.id;
|
||||
const restoredQuery = getLastSearchQuery(siteSpace.id);
|
||||
if (!restoredQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setSearchState(
|
||||
(prev) =>
|
||||
prev ?? {
|
||||
ask: null,
|
||||
query: restoredQuery,
|
||||
scope: 'default',
|
||||
open: true,
|
||||
}
|
||||
);
|
||||
}, [options.restoreLastQueryOnMount, setSearchState, siteSpace.id]);
|
||||
|
||||
const withAI = assistants.length > 0;
|
||||
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
|
||||
|
||||
@@ -141,38 +176,46 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
|
||||
const onClose = React.useCallback(
|
||||
async (to?: string) => {
|
||||
setSearchState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
open: false,
|
||||
query: prev.query === '' ? null : prev.query,
|
||||
}
|
||||
: null
|
||||
);
|
||||
setSearchState((prev) => {
|
||||
if (!prev) return null;
|
||||
|
||||
if (prev.query !== null) {
|
||||
setLastSearchQuery(siteSpace.id, prev.query);
|
||||
}
|
||||
|
||||
return { ...prev, open: false, query: null };
|
||||
});
|
||||
|
||||
if (to) {
|
||||
router.push(to);
|
||||
}
|
||||
},
|
||||
[setSearchState, router]
|
||||
[setSearchState, router, siteSpace.id]
|
||||
);
|
||||
|
||||
const onOpen = React.useCallback(() => {
|
||||
if (state?.open) {
|
||||
return;
|
||||
}
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||
open: true,
|
||||
}));
|
||||
setSearchState((prev) => {
|
||||
const query =
|
||||
prev?.query ??
|
||||
getLastSearchQuery(siteSpace.id) ??
|
||||
(withSearchAI || !withAI ? prev?.ask : null) ??
|
||||
'';
|
||||
|
||||
return {
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query,
|
||||
open: true,
|
||||
};
|
||||
});
|
||||
|
||||
trackEvent({
|
||||
type: 'search_open',
|
||||
});
|
||||
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
|
||||
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
|
||||
|
||||
const setQuery = React.useCallback(
|
||||
(value: string) => {
|
||||
@@ -212,9 +255,25 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
withSections,
|
||||
});
|
||||
|
||||
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
|
||||
const lastSearchQuery = useLastSearchQuery(siteSpace.id);
|
||||
const searchValue =
|
||||
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
|
||||
const searchResultsId = `search-results-${React.useId()}`;
|
||||
|
||||
const onResultSelect = React.useCallback(
|
||||
(result: ResultType) => {
|
||||
clearLastSearchQuery(siteSpace.id);
|
||||
abort();
|
||||
|
||||
if (result.type !== 'recommended-question') {
|
||||
void setSearchState((prev) =>
|
||||
prev ? { ...prev, query: null, open: false } : null
|
||||
);
|
||||
}
|
||||
},
|
||||
[abort, setSearchState, siteSpace.id]
|
||||
);
|
||||
|
||||
const askInAssistant = React.useCallback(
|
||||
(assistantIndex = 0) => {
|
||||
const assistant = assistants[assistantIndex];
|
||||
@@ -260,6 +319,7 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
abort,
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
onResultSelect,
|
||||
query: normalizedQuery,
|
||||
results,
|
||||
resultsId: searchResultsId,
|
||||
|
||||
Reference in New Issue
Block a user