Skip to content

Commit ced5556

Browse files
authored
fix(docs): keep search responsive and contain sidebar scrolling (#9786)
* fix(docs): keep search responsive and contain sidebar scrolling * fix(docs): strengthen search cancellation and IPv6 checks
1 parent c808a88 commit ced5556

12 files changed

Lines changed: 339 additions & 113 deletions

File tree

‎apps/docs/components/docs/navigation-drawer.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function DocsNavigationDrawer({ children }: DocsNavigationDrawerProps) {
5454
}}
5555
className={cn(
5656
docsScrollAreaClass,
57-
'-mx-1 mt-5 min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 pb-6'
57+
'-mx-1 mt-5 min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-1 pb-6'
5858
)}
5959
>
6060
<SidebarNavigation

‎apps/docs/components/docs/shell.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function DocsShell({ children, navigationBySection }: DocsShellProps) {
9898
key={section}
9999
className={cn(
100100
docsScrollAreaClass,
101-
'-mx-1 sticky top-24 hidden max-h-[calc(100dvh-120px)] min-w-0 space-y-5 overflow-y-auto overscroll-contain px-1 pb-5 lg:block'
101+
'-mx-1 sticky top-24 hidden max-h-[calc(100dvh-120px)] min-w-0 space-y-5 overflow-y-auto overflow-x-hidden overscroll-contain px-1 pb-5 lg:block'
102102
)}
103103
>
104104
<DocsSearchTrigger />

‎apps/docs/hooks/queries/search.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { z } from 'zod'
55

66
const SEARCH_STALE_TIME = 60_000
77
const SEARCH_GC_TIME = 300_000
8+
const SEARCH_TIMEOUT_MS = 5000
89
const searchKeys = {
910
all: ['docs-search'] as const,
1011
query: (query: string) => [...searchKeys.all, query] as const,
@@ -28,9 +29,21 @@ export function useDocsSearch(query: string) {
2829
gcTime: SEARCH_GC_TIME,
2930
retry: false,
3031
queryFn: async ({ signal }) => {
31-
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`, { signal })
32-
if (!response.ok) throw new Error('Search is unavailable. Try again.')
33-
return resultsSchema.parse(await response.json())
32+
const controller = new AbortController()
33+
const abort = () => controller.abort(signal.reason)
34+
signal.addEventListener('abort', abort, { once: true })
35+
if (signal.aborted) abort()
36+
const timeout = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
37+
try {
38+
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`, {
39+
signal: controller.signal,
40+
})
41+
if (!response.ok) throw new Error('Search is unavailable. Try again.')
42+
return resultsSchema.parse(await response.json())
43+
} finally {
44+
clearTimeout(timeout)
45+
signal.removeEventListener('abort', abort)
46+
}
3447
},
3548
})
3649
}

‎apps/docs/lib/search/build.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,40 @@
1-
import type { DocsPage } from '@/lib/content/types'
1+
import { findNavigationGroup } from '@/lib/content/navigation'
2+
import type { ContentCatalog, DocsPage } from '@/lib/content/types'
23
import { pageSearchChunks } from '@/lib/search/chunks'
3-
import type { SearchIndex } from '@/lib/search/types'
4+
import type { SearchIndex, SearchPage } from '@/lib/search/types'
45
import { normalizeSearchText, searchCatalogHash } from '@/lib/search/utils'
56

67
const MAX_SEARCH_INDEX_BYTES = 64 * 1024 * 1024
8+
const MAX_SEARCH_CATALOG_BYTES = 4 * 1024 * 1024
9+
const SECTION_LABELS = { docs: 'Documentation', api: 'API reference', cli: 'CLI', mcp: 'MCP' }
10+
11+
/** Search citations need document identity and navigation, without loading document bodies. */
12+
export function buildSearchCatalog(catalog: ContentCatalog): SearchPage[] {
13+
const pages: SearchPage[] = []
14+
let bytes = 0
15+
for (const page of catalog.pages) {
16+
if (page.url === '/') continue
17+
const section = SECTION_LABELS[page.section]
18+
const group = findNavigationGroup(catalog.navigation[page.section], page.url)
19+
const entry: SearchPage = {
20+
id: page.id,
21+
url: page.url,
22+
title: page.title,
23+
contentHash: page.contentHash,
24+
headings: page.headings.map((heading) => heading.id),
25+
breadcrumbs: [
26+
section,
27+
...(group && group !== section && group !== page.title ? [group] : []),
28+
page.title,
29+
],
30+
}
31+
bytes += Buffer.byteLength(JSON.stringify(entry)) + 1
32+
if (bytes > MAX_SEARCH_CATALOG_BYTES - 2)
33+
throw new Error('Generated search catalog exceeds the 4 MiB size budget')
34+
pages.push(entry)
35+
}
36+
return pages
37+
}
738

839
/** Build-time parsing keeps the public search cold path independent of corpus Markdown size. */
940
export function buildSearchIndex(pages: DocsPage[]): SearchIndex {
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import 'server-only'
2-
import { getPages } from '@/lib/content/catalog'
3-
import type { SearchIndex } from '@/lib/search/types'
2+
import type { SearchIndex, SearchPage } from '@/lib/search/types'
43
import { searchCatalogHash } from '@/lib/search/utils'
54
import generatedIndex from '@/.content/search.json'
5+
import generatedCatalog from '@/.content/search-catalog.json'
66

77
const index = generatedIndex as SearchIndex
8-
if (index.version !== 1 || index.catalogHash !== searchCatalogHash(getPages()))
8+
const pages = generatedCatalog as SearchPage[]
9+
const pagesByUrl = new Map(pages.map((page) => [page.url, page]))
10+
if (index.version !== 1 || index.catalogHash !== searchCatalogHash(pages))
911
throw new Error('Search index does not match the documentation catalog. Run content:generate.')
1012

1113
/** An immutable server-only index, generated and published with the canonical catalog. */
1214
export function getSearchIndex() {
1315
return index.entries
1416
}
17+
18+
export function getSearchPage(path: string) {
19+
return pagesByUrl.get(path)
20+
}

‎apps/docs/lib/search/service.ts‎

Lines changed: 91 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { createLogger } from '@sim/logger'
22
import { compareStrings, truncateAtCodePoint } from '@sim/utils/string'
3-
import { getNavigation, getPage } from '@/lib/content/catalog'
4-
import { findNavigationGroup } from '@/lib/content/navigation'
5-
import type { DocsPage } from '@/lib/content/types'
63
import { generateSearchEmbedding } from '@/lib/embeddings'
7-
import { getSearchIndex } from '@/lib/search/precomputed'
8-
import type { SearchResult } from '@/lib/search/types'
4+
import { getSearchIndex, getSearchPage } from '@/lib/search/precomputed'
5+
import type { SearchPage, SearchResult } from '@/lib/search/types'
96
import { normalizeSearchText } from '@/lib/search/utils'
107
import { DOCS_BASE_URL } from '@/lib/urls'
118

@@ -24,22 +21,16 @@ const STOP_WORDS = new Set([
2421
'the',
2522
'to',
2623
])
27-
const SECTION_LABELS = { docs: 'Documentation', api: 'API reference', cli: 'CLI', mcp: 'MCP' }
24+
const HYBRID_TIMEOUT_MS = 1000
2825
let hybridRequests = 0
2926

30-
function resultFor(page: DocsPage, id: string, url: string, heading: string): SearchResult {
31-
const section = SECTION_LABELS[page.section]
32-
const group = findNavigationGroup(getNavigation(page.section), page.url)
27+
function resultFor(page: SearchPage, id: string, url: string, heading: string): SearchResult {
3328
return {
3429
id,
3530
type: 'page',
3631
url,
3732
content: heading || page.title,
38-
breadcrumbs: [
39-
section,
40-
...(group && group !== section && group !== page.title ? [group] : []),
41-
page.title,
42-
],
33+
breadcrumbs: [...page.breadcrumbs],
4334
}
4435
}
4536

@@ -74,7 +65,7 @@ function lexicalSearch(query: string, limit: number): SearchResult[] {
7465
score += body.includes(phrase) ? 12 : 0
7566
const existing = scores.get(chunk.pageId)
7667
if (existing && existing.score >= score) continue
77-
const page = getPage(new URL(chunk.sourceLink).pathname)
68+
const page = getSearchPage(new URL(chunk.sourceLink).pathname)
7869
if (!page) continue
7970
scores.set(chunk.pageId, {
8071
score,
@@ -97,76 +88,94 @@ interface DatabaseResult {
9788
metadata: { contentHash?: string }
9889
}
9990

100-
async function hybridSearch(query: string, limit: number, signal?: AbortSignal) {
91+
function hybridResults(lists: DatabaseResult[][], limit: number): SearchResult[] {
92+
const scores = new Map<string, { score: number; result: SearchResult }>()
93+
for (const list of lists) {
94+
const seenPages = new Set<string>()
95+
list.forEach((row, index) => {
96+
let citation: URL
97+
try {
98+
citation = new URL(row.source_link)
99+
} catch {
100+
return
101+
}
102+
if (citation.origin !== new URL(DOCS_BASE_URL).origin) return
103+
const page = getSearchPage(citation.pathname)
104+
if (!page || row.metadata?.contentHash !== page.contentHash) return
105+
if (citation.hash && !page.headings.includes(citation.hash.slice(1))) return
106+
if (seenPages.has(page.id)) return
107+
seenPages.add(page.id)
108+
const existing = scores.get(page.id)
109+
const score = (existing?.score ?? 0) + 1 / (60 + index + 1)
110+
scores.set(page.id, {
111+
score,
112+
result: existing?.result ?? resultFor(page, row.chunk_id, citation.href, row.header_text),
113+
})
114+
})
115+
}
116+
return [...scores.values()]
117+
.sort(
118+
(left, right) => right.score - left.score || compareStrings(left.result.url, right.result.url)
119+
)
120+
.slice(0, limit)
121+
.map(({ result }) => result)
122+
}
123+
124+
async function hybridSearch(
125+
query: string,
126+
limit: number,
127+
signal: AbortSignal,
128+
onResults: (results: SearchResult[]) => void
129+
) {
101130
const { searchDatabase } = await import('@/lib/search/database')
131+
if (signal.aborted) return []
102132
const sql = searchDatabase()
103-
const embedding = await generateSearchEmbedding(query, signal).catch(() => null)
104-
if (signal?.aborted) return []
105133
const keyword = sql<DatabaseResult[]>`
106134
SELECT chunk_id, source_document, source_link, header_text, metadata
107135
FROM docs_embeddings
108136
WHERE chunk_text_tsv @@ plainto_tsquery('english', ${query})
109137
ORDER BY ts_rank(chunk_text_tsv, plainto_tsquery('english', ${query})) DESC, source_link
110138
LIMIT ${limit * 3}
111139
`
112-
const vector = embedding
113-
? sql<DatabaseResult[]>`
140+
let vector: typeof keyword | undefined
141+
const sources: DatabaseResult[][] = [[], []]
142+
async function collect(source: number, request: PromiseLike<DatabaseResult[]>) {
143+
const rows = await request
144+
if (signal.aborted) return
145+
sources[source] = rows
146+
onResults(hybridResults(sources, limit))
147+
}
148+
async function collectVector() {
149+
const embedding = await generateSearchEmbedding(query, signal).catch(() => null)
150+
if (!embedding || signal.aborted) return
151+
vector = sql<DatabaseResult[]>`
114152
SELECT chunk_id, source_document, source_link, header_text, metadata
115153
FROM docs_embeddings
116154
WHERE 1 - (embedding <=> ${JSON.stringify(embedding)}::vector) >= 0.6
117155
ORDER BY embedding <=> ${JSON.stringify(embedding)}::vector, source_link
118156
LIMIT ${limit * 3}
119157
`
120-
: undefined
158+
await collect(1, vector)
159+
}
121160
const cancel = () => {
122161
keyword.cancel()
123162
vector?.cancel()
124163
}
125-
signal?.addEventListener('abort', cancel, { once: true })
164+
signal.addEventListener('abort', cancel, { once: true })
126165
try {
127-
const lists = await Promise.allSettled([keyword, vector ?? Promise.resolve([])])
128-
const scores = new Map<string, { score: number; result: SearchResult }>()
166+
const lists = await Promise.allSettled([collect(0, keyword), collectVector()])
129167
for (const [source, list] of lists.entries()) {
130168
if (list.status === 'rejected') {
131-
if (!signal?.aborted)
169+
if (!signal.aborted)
132170
logger.warn('Hybrid search query unavailable; serving remaining results', {
133171
source: source === 0 ? 'keyword' : 'vector',
134172
error: list.reason,
135173
})
136-
continue
137174
}
138-
const seenPages = new Set<string>()
139-
list.value.forEach((row, index) => {
140-
let citation: URL
141-
try {
142-
citation = new URL(row.source_link)
143-
} catch {
144-
return
145-
}
146-
if (citation.origin !== new URL(DOCS_BASE_URL).origin) return
147-
const page = getPage(citation.pathname)
148-
if (!page || row.metadata?.contentHash !== page.contentHash) return
149-
if (citation.hash && !page.headings.some((heading) => `#${heading.id}` === citation.hash))
150-
return
151-
if (seenPages.has(page.id)) return
152-
seenPages.add(page.id)
153-
const existing = scores.get(page.id)
154-
const score = (existing?.score ?? 0) + 1 / (60 + index + 1)
155-
scores.set(page.id, {
156-
score,
157-
result: existing?.result ?? resultFor(page, row.chunk_id, citation.href, row.header_text),
158-
})
159-
})
160175
}
161-
return [...scores.values()]
162-
.sort(
163-
(left, right) =>
164-
right.score - left.score || compareStrings(left.result.url, right.result.url)
165-
)
166-
.slice(0, limit)
167-
.map(({ result }) => result)
176+
return hybridResults(sources, limit)
168177
} finally {
169-
signal?.removeEventListener('abort', cancel)
178+
signal.removeEventListener('abort', cancel)
170179
}
171180
}
172181

@@ -182,35 +191,34 @@ export async function searchDocs(
182191
: 10
183192
if (!query) return []
184193
const lexical = lexicalSearch(query, limit)
185-
if (!process.env.DATABASE_URL || hybridRequests >= 2 || signal?.aborted) return lexical
194+
if (lexical.length || !process.env.DATABASE_URL || hybridRequests >= 2 || signal?.aborted)
195+
return lexical
196+
const controller = new AbortController()
197+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
198+
let partial: SearchResult[] = []
199+
let onAbort: (() => void) | undefined
200+
const aborted = new Promise<SearchResult[]>((resolve) => {
201+
onAbort = () => resolve(partial)
202+
combined.addEventListener('abort', onAbort, { once: true })
203+
if (combined.aborted) onAbort()
204+
})
205+
const timeout = setTimeout(() => controller.abort(), HYBRID_TIMEOUT_MS)
186206
hybridRequests += 1
207+
const pending = hybridSearch(query, limit, combined, (results) => {
208+
partial = results
209+
})
210+
.catch((error) => {
211+
if (!combined.aborted)
212+
logger.warn('Hybrid search unavailable; serving local results', { error })
213+
return partial
214+
})
215+
.finally(() => {
216+
hybridRequests -= 1
217+
})
187218
try {
188-
const hybrid = await hybridSearch(query, limit, signal)
189-
const results = new Map<string, { score: number; result: SearchResult }>()
190-
for (const list of [lexical, hybrid]) {
191-
list.forEach((result, rank) => {
192-
const path = new URL(result.url).pathname
193-
const existing = results.get(path)
194-
const title = getPage(path)?.title
195-
const exactTitle =
196-
title && normalizeSearchText(title) === normalizeSearchText(query) ? 0.05 : 0
197-
results.set(path, {
198-
score: (existing?.score ?? exactTitle) + 1 / (60 + rank + 1),
199-
result: existing?.result ?? result,
200-
})
201-
})
202-
}
203-
return [...results.values()]
204-
.sort(
205-
(left, right) =>
206-
right.score - left.score || compareStrings(left.result.url, right.result.url)
207-
)
208-
.slice(0, limit)
209-
.map(({ result }) => result)
210-
} catch (error) {
211-
if (!signal?.aborted) logger.warn('Hybrid search unavailable; serving local results', { error })
212-
return lexical
219+
return await Promise.race([pending, aborted])
213220
} finally {
214-
hybridRequests -= 1
221+
clearTimeout(timeout)
222+
if (onAbort) combined.removeEventListener('abort', onAbort)
215223
}
216224
}

‎apps/docs/lib/search/types.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ export interface SearchResult {
66
breadcrumbs: string[]
77
}
88

9+
export interface SearchPage {
10+
id: string
11+
url: string
12+
title: string
13+
contentHash: string
14+
headings: string[]
15+
breadcrumbs: string[]
16+
}
17+
918
export interface SearchChunk {
1019
id: string
1120
pageId: string

‎apps/docs/lib/search/utils.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function normalizeSearchText(value: string): string {
77
}
88

99
/** Couples generated search data to exactly the catalog content it cites. */
10-
export function searchCatalogHash(pages: DocsPage[]): string {
10+
export function searchCatalogHash(pages: Pick<DocsPage, 'id' | 'url' | 'contentHash'>[]): string {
1111
const identities = pages
1212
.filter((page) => page.url !== '/')
1313
.map((page) => `${page.id}:${page.contentHash}`)

0 commit comments

Comments
 (0)