diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 4fa3d73b36..3a9a40361c 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -181,7 +181,7 @@ export function SearchContainer(props: SearchContainerProps) { const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true; const searchResultsId = `search-results-${React.useId()}`; - const { results, fetching } = useSearchResults({ + const { results, fetching, error } = useSearchResults({ disabled: !(state?.query || withAI), query: normalizedQuery, siteSpaceId, @@ -232,6 +232,7 @@ export function SearchContainer(props: SearchContainerProps) { fetching={fetching} results={results} cursor={cursor} + error={error} /> ) : null} {showAsk ? : null} diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index b61b6d53d9..fac209aba5 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -7,7 +7,7 @@ import { type Assistant, useAI } from '@/components/AI'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -import { Loading } from '../primitives'; +import { Button, Loading } from '../primitives'; import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchSectionResultItem } from './SearchSectionResultItem'; @@ -36,10 +36,11 @@ export const SearchResults = React.forwardRef(function SearchResults( results: ResultType[]; fetching: boolean; cursor: number | null; + error: boolean; }, ref: React.Ref ) { - const { children, id, query, results, fetching, cursor } = props; + const { children, id, query, results, fetching, cursor, error } = props; const language = useLanguage(); @@ -82,6 +83,32 @@ export const SearchResults = React.forwardRef(function SearchResults( ); } + if (error) { + return ( +
+
{t(language, 'search_ask_error')}
+ +
+ ); + } const noResults = (
({ results: [], fetching: false }); + error: boolean; + }>({ results: [], fetching: false, error: false }); const { assistants } = useAI(); const withAI = assistants.length > 0; @@ -54,7 +55,7 @@ export function useSearchResults(props: { } if (!query) { if (!withAI) { - setResultsState({ results: [], fetching: false }); + setResultsState({ results: [], fetching: false, error: false }); return; } @@ -64,11 +65,11 @@ export function useSearchResults(props: { results, `Cached recommended questions should be set for site-space ${siteSpaceId}` ); - setResultsState({ results, fetching: false }); + setResultsState({ results, fetching: false, error: false }); return; } - setResultsState({ results: [], fetching: false }); + setResultsState({ results: [], fetching: false, error: false }); let cancelled = false; @@ -102,7 +103,11 @@ export function useSearchResults(props: { cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions); if (!cancelled) { - setResultsState({ results: [...recommendedQuestions], fetching: false }); + setResultsState({ + results: [...recommendedQuestions], + fetching: false, + error: false, + }); } } }, 100); @@ -112,44 +117,55 @@ export function useSearchResults(props: { clearTimeout(timeout); }; } - setResultsState((prev) => ({ results: prev.results, fetching: true })); + setResultsState((prev) => ({ results: prev.results, fetching: true, error: false })); let cancelled = false; const timeout = setTimeout(async () => { - const results = await (() => { - if (scope === 'all') { - // Search all content on the site - return searchAllSiteContent(query); - } - if (scope === 'default') { - // Search the current section's variant + matched/default variant for other sections - return searchCurrentSiteSpaceContent(query, siteSpaceId); - } - if (scope === 'extended') { - // Search all variants of the current section - return searchSpecificSiteSpaceContent(query, siteSpaceIds); + try { + const results = await (() => { + if (scope === 'all') { + // Search all content on the site + return searchAllSiteContent(query); + } + if (scope === 'default') { + // Search the current section's variant + matched/default variant for other sections + return searchCurrentSiteSpaceContent(query, siteSpaceId); + } + if (scope === 'extended') { + // Search all variants of the current section + return searchSpecificSiteSpaceContent(query, siteSpaceIds); + } + if (scope === 'current') { + // Search only the current section's current variant + return searchSpecificSiteSpaceContent(query, [siteSpaceId]); + } + throw new Error(`Unhandled search scope: ${scope}`); + })(); + + if (cancelled) { + return; } - if (scope === 'current') { - // Search only the current section's current variant - return searchSpecificSiteSpaceContent(query, [siteSpaceId]); + + 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. + setResultsState({ results: [], fetching: false, error: true }); + return; } - throw new Error(`Unhandled search scope: ${scope}`); - })(); - if (cancelled) { - return; - } + setResultsState({ results, fetching: false, error: false }); - if (!results) { - setResultsState({ results: [], fetching: false }); - return; + 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; + } + setResultsState({ results: [], fetching: false, error: true }); } - - setResultsState({ results, fetching: false }); - - trackEvent({ - type: 'search_type_query', - query, - }); }, 350); return () => { diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index c7c7e52e05..caa19c5421 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -202,6 +202,20 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // Handle redirects // if ('redirect' in siteURLData) { + // When it is a server action, we cannot just return a redirect response as it may cause CORS issues on redirect. + // For these cases, we return a 303 response with an `X-Action-Redirect` header that the client can handle. + // This is what server actions do when returning a redirect response. + const isServerAction = request.headers.has('next-action') && request.method === 'POST'; + const createRedirectResponse = (url: string) => + isServerAction + ? new NextResponse(null, { + status: 303, + headers: { + 'X-Action-Redirect': `${url};push`, + 'Content-Security-Policy': getContentSecurityPolicy(), + }, + }) + : NextResponse.redirect(url); // biome-ignore lint/suspicious/noConsole: we want to log the redirect console.log('redirect', siteURLData.redirect); if (siteURLData.target === 'content') { @@ -221,10 +235,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // as it might contain a VA token contentRedirect.search = request.nextUrl.search; - return NextResponse.redirect(contentRedirect); + return createRedirectResponse(contentRedirect.toString()); } - return NextResponse.redirect(siteURLData.redirect); + return createRedirectResponse(siteURLData.redirect); } cookies.push(