feat(docs): update search v2 ui (#51175)

This commit is contained in:
Anthony Lio authored and GitHub committed 2026-10-05 20:33:19 +00:00
1 parent 47fd296fc1
commit dcf266c360
14 files changed
+425 -108

No files matched your search

@@ -51,7 +51,6 @@ const TopNavBar: FC = () => {
<DevToolbarTrigger />
{searchVariant === 'search-v2-active' ? (
<SearchV2Trigger
className="[&>div>p]:text-foreground-lighter"
placeholder={
<>
Search
@@ -20,13 +20,13 @@ describe('formatHeadingPath', () => {
function renderHighlight(text: string, query: string) {
const result = highlightMatches(text, query)
if (typeof result === 'string') return { html: result, strongTexts: [] as string[] }
if (typeof result === 'string') return { html: result, markTexts: [] as string[] }
const html = renderToStaticMarkup(<>{result}</>)
const $ = load(html)
return {
html: $.root().text(),
strongTexts: $('strong')
markTexts: $('mark')
.map((_, el) => $(el).text())
.get(),
}
@@ -37,19 +37,19 @@ describe('highlightMatches', () => {
const result = highlightMatches('Bring your own MCP', 'mcp server')
expect(typeof result).not.toBe('string')
const { strongTexts, html } = renderHighlight('Bring your own MCP', 'mcp server')
expect(strongTexts).toEqual(['MCP'])
const { markTexts, html } = renderHighlight('Bring your own MCP', 'mcp server')
expect(markTexts).toEqual(['MCP'])
expect(html).toBe('Bring your own MCP')
})
it('highlights multiple non-overlapping token matches independently', () => {
const { strongTexts } = renderHighlight('MCP servers for your server', 'mcp server')
expect(strongTexts).toEqual(['MCP', 'server', 'server'])
const { markTexts } = renderHighlight('MCP servers for your server', 'mcp server')
expect(markTexts).toEqual(['MCP', 'server', 'server'])
})
it('merges overlapping/adjacent matches into a single run', () => {
const { strongTexts } = renderHighlight('server', 'server serv')
expect(strongTexts).toEqual(['server'])
const { markTexts } = renderHighlight('server', 'server serv')
expect(markTexts).toEqual(['server'])
})
it('returns the original string unchanged when there is no match', () => {
@@ -58,8 +58,8 @@ describe('highlightMatches', () => {
})
it('is case-insensitive but preserves the original casing of the matched text', () => {
const { strongTexts } = renderHighlight('Bring your own MCP', 'MCP')
expect(strongTexts).toEqual(['MCP'])
const { markTexts } = renderHighlight('Bring your own MCP', 'MCP')
expect(markTexts).toEqual(['MCP'])
})
it('returns the text unchanged for an empty or whitespace-only query', () => {
@@ -68,8 +68,8 @@ describe('highlightMatches', () => {
})
it('excludes common prepositions/articles/conjunctions from highlighting', () => {
const { strongTexts } = renderHighlight('The best MCP server for you', 'the mcp server')
expect(strongTexts).toEqual(['MCP', 'server'])
const { markTexts } = renderHighlight('The best MCP server for you', 'the mcp server')
expect(markTexts).toEqual(['MCP', 'server'])
})
it('returns the text unchanged when the query is made up entirely of ignored words', () => {
+27 -3
View File
@@ -1,5 +1,13 @@
import type { useDocsSearchV2 } from 'common'
import type { ReactNode } from 'react'
type DocsSearchV2State = ReturnType<typeof useDocsSearchV2>['searchState']
interface GetIsSearchingParams {
searchState: DocsSearchV2State
query: string
}
/** Common English prepositions/articles/conjunctions, excluded from highlighting so a query like "the mcp server" doesn't bold "the". */
const IGNORED_WORDS = new Set([
'a',
@@ -65,7 +73,7 @@ function getMatchRanges(text: string, query: string): Array<[number, number]> {
/**
* Highlight every case-insensitive, per-word partial match of `query` inside `text`.
* Returns the plain string when there's no match, otherwise a fragment with matches
* wrapped in <strong>, preserving the original casing of `text`.
* wrapped in <mark>, preserving the original casing of `text`.
*/
function highlightMatches(text: string, query: string): ReactNode {
const ranges = getMatchRanges(text, query)
@@ -75,7 +83,11 @@ function highlightMatches(text: string, query: string): ReactNode {
let cursor = 0
ranges.forEach(([start, end], i) => {
if (start > cursor) nodes.push(text.slice(cursor, start))
nodes.push(<strong key={i}>{text.slice(start, end)}</strong>)
nodes.push(
<mark key={i} className="-mx-px rounded-sm bg-primary-bright/15 px-px text-primary">
{text.slice(start, end)}
</mark>
)
cursor = end
})
if (cursor < text.length) nodes.push(text.slice(cursor))
@@ -83,4 +95,16 @@ function highlightMatches(text: string, query: string): ReactNode {
return <>{nodes}</>
}
export { formatHeadingPath, highlightMatches }
// drives the loading beam: on while a request is in flight or about to be sent
function getIsSearching({ searchState, query }: GetIsSearchingParams): boolean {
if (searchState.status === 'loading') return true
if (searchState.status === 'error') return false
const trimmedQuery = query.trim()
const settledQuery = 'query' in searchState ? searchState.query : null
// so a search is on its way and the beam starts on the keystroke, not when the request fires
return trimmedQuery !== '' && trimmedQuery !== settledQuery
}
export { formatHeadingPath, getIsSearching, highlightMatches }
+148 -75
View File
@@ -1,10 +1,10 @@
'use client'
import { LoadingBeam } from '~/features/ui/LoadingBeam'
import { useDocsSearchV2, type DocsSearchV2Result } from 'common'
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { VisuallyHidden } from 'radix-ui'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent } from 'react'
import {
Command,
CommandEmpty,
@@ -16,9 +16,11 @@ import {
DialogContent,
DialogDescription,
DialogTitle,
KeyboardShortcut,
} from 'ui'
import { formatHeadingPath, highlightMatches } from './SearchV2.utils'
import { getIsSearching } from './SearchV2.utils'
import { SearchV2Result } from './SearchV2Result'
import { useSendTelemetryEvent } from '@/lib/telemetry'
interface SearchV2DialogProps {
@@ -26,28 +28,62 @@ interface SearchV2DialogProps {
onOpenChange: (open: boolean) => void
}
interface SearchV2PanelProps {
onResultSelect: (path: string) => void
onResultOpen: () => void
}
export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
const router = useRouter()
function handleSelect(path: string) {
router.push(path)
onOpenChange(false)
}
function handleResultOpen() {
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
hideClose
centered={false}
dialogOverlayProps={{
className:
'data-closed:animate-out! data-closed:fade-out-0 data-closed:fill-mode-forwards data-closed:duration-150 data-closed:ease-enter pt-20 sm:pt-20',
}}
size="large"
className="overflow-hidden rounded-lg border-0 p-0 inset-ring inset-ring-border shadow-(--shadow-codeblock)!"
>
<SearchV2Panel onResultSelect={handleSelect} onResultOpen={handleResultOpen} />
</DialogContent>
</Dialog>
)
}
function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) {
const sendTelemetryEvent = useSendTelemetryEvent()
const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2()
const [highlightQuery, setHighlightQuery] = useState('')
const [query, setQuery] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Clear stale results once the dialog closes
useEffect(() => {
if (!open) resetSearch()
}, [open, resetSearch])
// highlight with the query the visible results belong to, not the one being typed
const highlightQuery =
'query' in searchState
? searchState.query
: 'staleQuery' in searchState
? searchState.staleQuery
: ''
// Only update the highlighted query once a new result set actually lands, so highlights
// don't shift on every keystroke while the debounced search is still in flight.
useEffect(() => {
if (searchState.status === 'results' || searchState.status === 'noResults') {
setHighlightQuery(searchState.query)
sendTelemetryEvent({
action: 'docs_search_v2_search_submitted',
properties: { query: searchState.query },
})
} else if (searchState.status === 'initial') {
setHighlightQuery('')
}
}, [searchState, sendTelemetryEvent])
@@ -58,7 +94,25 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
? searchState.staleResults
: []
const isListVisible =
results.length > 0 || searchState.status === 'noResults' || searchState.status === 'error'
const isSearching = getIsSearching({ searchState, query })
function handleClear() {
setQuery('')
resetSearch()
inputRef.current?.focus()
}
// cmdk's root turns enter into "open highlighted result", so keep it on the button.
// other keys must bubble, or tab never reaches the dialog's focus trap
function handleClearKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === 'Enter') event.stopPropagation()
}
function handleValueChange(value: string) {
setIsDeleting(value.length < query.length)
setQuery(value)
if (value) {
handleDocsSearchDebounced(value)
} else {
@@ -71,8 +125,7 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
action: 'docs_search_v2_result_clicked',
properties: { resultPath: path, query: highlightQuery },
})
router.push(path)
onOpenChange(false)
onResultSelect(path)
}
// Announced via the aria-live region below — a sighted user sees the spinner/list update,
@@ -89,69 +142,89 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* hideClose: this is a search box, not a form — closing is Escape/click-outside only, no "X" */}
<DialogContent hideClose size="large" className="overflow-hidden p-0 shadow-lg">
<Command>
<VisuallyHidden.VisuallyHidden>
<DialogTitle>Search docs</DialogTitle>
<DialogDescription>Search the Supabase documentation</DialogDescription>
</VisuallyHidden.VisuallyHidden>
<CommandInput
placeholder="Search docs..."
aria-label="Search the Supabase documentation"
onValueChange={handleValueChange}
wrapperClassName="[&_svg]:h-5 [&_svg]:w-5 border-0"
className="h-14 text-base"
/>
{/*
<Command shouldFilter={false} className="bg-transparent">
<VisuallyHidden.VisuallyHidden>
<DialogTitle>Search docs</DialogTitle>
<DialogDescription>Search the Supabase documentation</DialogDescription>
</VisuallyHidden.VisuallyHidden>
<div className="relative flex items-center gap-3 pr-4">
<CommandInput
ref={inputRef}
value={query}
placeholder="Search docs..."
aria-label="Search the Supabase documentation"
onValueChange={handleValueChange}
wrapperClassName="flex-1 border-0 pl-3 pr-0 text-foreground-lighter [&_svg]:size-4.5 [&_svg]:stroke-2 [&_svg]:opacity-100"
className="h-12 pl-2.5 text-base text-foreground placeholder:text-foreground-lighter"
/>
{query ? (
<button
type="button"
tabIndex={0}
onClick={handleClear}
onKeyDown={handleClearKeyDown}
className="relative -mx-2 shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs text-foreground-lighter transition-colors duration-150 after:absolute after:inset-x-0 after:-inset-y-2 after:content-[''] hover:bg-overlay-hover hover:text-foreground focus-ring"
>
Clear
</button>
) : null}
<LoadingBeam
isActive={isSearching}
direction={isDeleting ? 'backward' : 'forward'}
className={isListVisible ? '-bottom-px' : undefined}
/>
</div>
{/*
Screen-reader-only status announcement. Focus stays in the input as results come in
(that's what lets people keep typing), so nothing else here gets read aloud on its
own — this is what tells a screen reader user a search ran and how many results it found.
*/}
<div role="status" aria-live="polite" className="sr-only">
{getStatusMessage()}
</div>
<CommandList label="Search results">
{searchState.status === 'initial' && (
<CommandEmpty>Start typing to search the docs.</CommandEmpty>
)}
{searchState.status === 'loading' && results.length === 0 && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground-muted">
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
Searching...
</div>
)}
{searchState.status === 'noResults' && <CommandEmpty>No results found.</CommandEmpty>}
{searchState.status === 'error' && (
<CommandEmpty>Something went wrong. Please try again.</CommandEmpty>
)}
{results.length > 0 && (
<CommandGroup heading="Results" forceMount>
{results.map((page) => (
<CommandItem
key={page.path}
value={page.path}
forceMount
onSelect={() => handleSelect(page.path)}
>
<div className="flex flex-col">
<span className="text-sm">
{highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)}
</span>
{page.excerpt && (
<span className="text-xs text-foreground-muted">
{highlightMatches(page.excerpt, highlightQuery)}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
<div role="status" aria-live="polite" className="sr-only">
{getStatusMessage()}
</div>
<CommandList
label="Search results"
className="h-(--cmdk-list-height) max-h-[min(477px,70dvh)] inset-shadow-2xs inset-shadow-border mx-px transition-all duration-150 ease-enter motion-reduce:transition-none scroll-fade-bottom"
>
{searchState.status === 'noResults' && <CommandEmpty>No results found.</CommandEmpty>}
{searchState.status === 'error' && (
<CommandEmpty>Something went wrong. Please try again.</CommandEmpty>
)}
{results.length > 0 && (
<CommandGroup className="pt-1.25">
{results.map((result) => (
<SearchV2Result
key={result.path}
result={result}
highlightQuery={highlightQuery}
onResultSelect={handleSelect}
onResultOpen={onResultOpen}
/>
))}
</CommandGroup>
)}
</CommandList>
{results.length > 0 ? <SearchV2Footer /> : null}
</Command>
)
}
function SearchV2Footer() {
return (
<footer
aria-hidden
className="flex items-center justify-end gap-3 border-t px-4 py-2.5 text-xs text-foreground-lighter mx-px max-lg:hidden"
>
<span className="flex items-center gap-1.5">
Navigate
<KeyboardShortcut keys={['ArrowUp']} />
<KeyboardShortcut keys={['ArrowDown']} />
</span>
<span className="h-3 w-px bg-border" />
<span className="flex items-center gap-1.5">
Open
<KeyboardShortcut keys={['Enter']} />
</span>
</footer>
)
}
@@ -0,0 +1,61 @@
'use client'
import type { DocsSearchV2Result } from 'common'
import Link from 'next/link'
import { useRef, type MouseEvent } from 'react'
import { CommandItem } from 'ui'
import { formatHeadingPath, highlightMatches } from './SearchV2.utils'
interface SearchV2ResultProps {
result: DocsSearchV2Result
highlightQuery: string
onResultSelect: (path: string) => void
onResultOpen: () => void
}
export function SearchV2Result({
result,
highlightQuery,
onResultSelect,
onResultOpen,
}: SearchV2ResultProps) {
// clicks navigate via the link and also fire onSelect, so onSelect only navigates for Enter
const isPointerSelectRef = useRef(false)
function handleLinkClick(event: MouseEvent<HTMLAnchorElement>) {
isPointerSelectRef.current = true
const isNewTab = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
if (!isNewTab) onResultOpen()
}
function handleSelect() {
if (isPointerSelectRef.current) {
isPointerSelectRef.current = false
return
}
onResultSelect(result.path)
}
return (
<CommandItem
asChild
value={result.path}
onSelect={handleSelect}
className="cursor-pointer rounded-md px-2 py-2"
>
<Link href={result.path} prefetch={false} tabIndex={-1} onClick={handleLinkClick}>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-sm leading-5 text-foreground">
{highlightMatches(formatHeadingPath(result.headingPath), highlightQuery)}
</p>
{result.excerpt ? (
<p className="line-clamp-2 text-pretty text-sm leading-5 text-foreground-lighter">
{highlightMatches(result.excerpt, highlightQuery)}
</p>
) : null}
</div>
</Link>
</CommandItem>
)
}
@@ -66,30 +66,22 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search
})
}}
className={cn(
'group cursor-pointer',
'cursor-pointer',
'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md',
'pl-1.5 md:pl-2 pr-1',
'pl-2 pr-1',
'flex items-center justify-between',
'bg-transparent text-foreground-lighter border border-strong',
'hover:bg-popover hover:border-control-hover',
'border border-default bg-surface-75 text-foreground-lighter shadow-(--shadow-codeblock)',
'hover:border-strong hover:text-foreground-light',
'focus-ring',
'transition-colors',
className
)}
>
<div className="flex items-center space-x-1.5 text-foreground-lighter">
<Search
size={16}
strokeWidth={1.5}
className="group-hover:text-foreground-light transition-colors"
/>
<p className="flex text-xs pr-2 text-foreground-muted">{placeholder}</p>
<div className="flex items-center gap-2">
<Search aria-hidden className="size-4 shrink-0" strokeWidth={2.25} />
<p className="flex pr-2 text-sm">{placeholder}</p>
</div>
<KeyboardShortcut
keys={['Meta', 'k']}
aria-hidden
className="hidden md:inline-flex border border-default bg-surface-300 text-foreground-lighter shadow-xs shadow-background-surface-100"
/>
<KeyboardShortcut keys={['Meta', 'k']} aria-hidden className="hidden md:inline-flex" />
</button>
<SearchV2Dialog open={open} onOpenChange={handleOpenChange} />
</>
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { useState } from 'react'
import { cn } from 'ui'
export interface LoadingBeamProps {
isActive: boolean
direction?: 'forward' | 'backward'
className?: string
}
export const LoadingBeam = ({ isActive, direction = 'forward', className }: LoadingBeamProps) => {
const [run, setRun] = useState(0)
const [runDirection, setRunDirection] = useState(direction)
const [wasActive, setWasActive] = useState(isActive)
if (isActive !== wasActive) {
setWasActive(isActive)
if (isActive) {
setRun((current) => current + 1)
setRunDirection(direction)
}
}
return (
<div
key={run}
aria-hidden
data-active={isActive ? '' : undefined}
data-direction={runDirection}
className={cn('loading-beam', className)}
/>
)
}
+1
View File
@@ -39,6 +39,7 @@
}
[data-theme='light'],
.light {
--shadow-codeblock: 0 0 #0000;
--code-token-keyword: #5f2fc4;
--code-foreground: oklch(from var(--foreground-light) l c h / 1);
--code-token-constant: #15593b;
+2
View File
@@ -1,6 +1,8 @@
@import 'config/tailwind.config.css';
@import './code-block.css';
@import './reference.css';
@import './loading-beam.css';
@import './scroll-fade.css';
@source '../app/**/*.{ts,tsx,mdx}';
@source '../components/**/*.tsx';
+80
View File
@@ -0,0 +1,80 @@
@property --loading-beam-x {
syntax: '<number>';
initial-value: 0.5;
inherits: true;
}
@keyframes loading-beam-sweep {
0%,
100% {
--loading-beam-x: 0;
}
50% {
--loading-beam-x: 1;
}
}
@layer components {
.loading-beam {
--loading-beam-color: var(--foreground-default);
--loading-beam-half: 100px;
--loading-beam-center: calc(
var(--loading-beam-half) + var(--loading-beam-x) * (100% - var(--loading-beam-half) * 2)
);
position: absolute;
inset: auto 0 0;
height: 16px;
overflow: hidden;
pointer-events: none;
opacity: 0;
transition: opacity 150ms ease-out;
animation: loading-beam-sweep 2s ease-in-out -0.25s infinite paused;
}
:is([data-theme='light'], .light) .loading-beam {
--loading-beam-color: var(--primary);
}
.loading-beam[data-direction='backward'] {
animation-delay: -1.25s;
}
.loading-beam[data-active] {
opacity: 1;
transition-duration: 250ms;
animation-play-state: running;
}
.loading-beam::before,
.loading-beam::after {
content: '';
position: absolute;
}
.loading-beam::before {
inset: 0 0 -8px;
filter: blur(4px);
background: radial-gradient(
120px 12px at var(--loading-beam-center) calc(100% - 8px),
color-mix(in oklch, var(--loading-beam-color) 3.2%, transparent),
transparent
);
}
.loading-beam::after {
inset: auto 0 0;
height: 1px;
background: radial-gradient(
var(--loading-beam-half) 4px at var(--loading-beam-center) 100%,
color-mix(in oklch, var(--loading-beam-color) 44.5%, transparent),
color-mix(in oklch, var(--loading-beam-color) 16%, transparent) 45%,
transparent
);
}
@media (prefers-reduced-motion: reduce) {
.loading-beam {
animation: none;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
@property --scroll-fade-size {
syntax: '<length>';
initial-value: 0px;
inherits: false;
}
@keyframes scroll-fade-bottom {
0%,
85% {
--scroll-fade-size: 24px;
}
100% {
--scroll-fade-size: 0px;
}
}
@layer components {
@supports (animation-timeline: scroll()) {
.scroll-fade-bottom {
mask-image: linear-gradient(
to bottom,
#000 calc(100% - var(--scroll-fade-size)),
rgb(0 0 0 / 0.8) calc(100% - var(--scroll-fade-size) * 0.75),
rgb(0 0 0 / 0.5) calc(100% - var(--scroll-fade-size) * 0.5),
rgb(0 0 0 / 0.2) calc(100% - var(--scroll-fade-size) * 0.25),
transparent 100%
);
animation: scroll-fade-bottom linear both;
animation-timeline: scroll(self);
}
}
}
+1
View File
@@ -11,6 +11,7 @@ export * from './useDocsSearchV2'
export * from './useDragToClose'
export * from './useEffectEvent'
export * from './useIsomorphicLayoutEffect'
export * from './useMountEffect'
export * from './useOnChange'
export * from './useParams'
export * from './useSearchParamsShallow'
+8 -1
View File
@@ -3,6 +3,8 @@
import { compact, debounce } from 'lodash'
import { useCallback, useMemo, useReducer, useRef } from 'react'
import { useMountEffect } from './useMountEffect'
// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs').
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
// Public URL of the docs deployment, which hosts the search API routes.
@@ -21,7 +23,7 @@ interface DocsSearchV2Result {
type SearchState =
| { status: 'initial'; key: number }
| { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] }
| { status: 'loading'; key: number; staleResults: DocsSearchV2Result[]; staleQuery: string }
| { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string }
| { status: 'noResults'; key: number; query: string }
| { status: 'error'; key: number; message: string }
@@ -74,6 +76,8 @@ function reducer(state: SearchState, action: Action): SearchState {
key: action.key,
staleResults:
'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [],
// keep highlighted query while loading
staleQuery: 'query' in state ? state.query : 'staleQuery' in state ? state.staleQuery : '',
}
case 'reset':
return { status: 'initial', key: action.key }
@@ -122,6 +126,9 @@ const useDocsSearchV2 = () => {
debouncedSearch.cancel()
}, [debouncedSearch])
// the dialog unmounts on close, so cancel any pending search instead of fetching after it's gone
useMountEffect(() => debounceCancel)
const resetSearch = useCallback(() => {
debounceCancel()
key.current += 1
+12
View File
@@ -0,0 +1,12 @@
'use client'
import { useEffect, type EffectCallback } from 'react'
/**
* runs an effect once on mount, with its cleanup on unmount, the escape hatch for one-time
* sync with an external system and derive state or use event handlers everywhere else
*/
export function useMountEffect(effect: EffectCallback): void {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, [])
}