Skip to content

Commit

Permalink
fix(structure): memoize search query results
Browse files Browse the repository at this point in the history
  • Loading branch information
bjoerge committed Sep 27, 2024
1 parent 5bfb22e commit c126395
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 213 deletions.
107 changes: 66 additions & 41 deletions packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {SearchIcon, SpinnerIcon} from '@sanity/icons'
import {Box, TextInput} from '@sanity/ui'
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {memo, useCallback, useEffect, useMemo, useState} from 'react'
import {useObservableEvent} from 'react-rx'
import {debounce, map, type Observable, of, tap, timer} from 'rxjs'
import {
Expand All @@ -14,7 +14,7 @@ import {keyframes, styled} from 'styled-components'

import {structureLocaleNamespace} from '../../i18n'
import {type BaseStructureToolPaneProps} from '../types'
import {EMPTY_RECORD} from './constants'
import {EMPTY_RECORD, FULL_LIST_LIMIT} from './constants'
import {DocumentListPaneContent} from './DocumentListPaneContent'
import {applyOrderingFunctions, findStaticTypesInFilter} from './helpers'
import {useShallowUnique} from './PaneContainer'
Expand All @@ -38,10 +38,34 @@ const rotate = keyframes`
}
`

const fadeIn = keyframes`
0% {
opacity: 0;
}
50% {
opacity: 0.1;
}
100% {
opacity: 0.4;
}
`

const AnimatedSpinnerIcon = styled(SpinnerIcon)`
animation: ${rotate} 500ms linear infinite;
`

const SubtleSpinnerIcon = styled(SpinnerIcon)`
animation: ${rotate} 1500ms linear infinite;
opacity: 0.4;
`

const DelayedSubtleSpinnerIcon = styled(SpinnerIcon)`
animation:
${rotate} 1500ms linear infinite,
${fadeIn} 1000ms linear;
opacity: 0.4;
`

/**
* @internal
*/
Expand All @@ -68,34 +92,21 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
const [searchInputValue, setSearchInputValue] = useState<string>('')
const [searchInputElement, setSearchInputElement] = useState<HTMLInputElement | null>(null)

// A ref to determine if we should show the loading spinner in the search input.
// This is used to avoid showing the spinner on initial load of the document list.
// We only wan't to show the spinner when the user interacts with the search input.
const showSearchLoadingRef = useRef<boolean>(false)

const sortWithOrderingFn =
typeName && sortOrderRaw
? applyOrderingFunctions(sortOrderRaw, schema.get(typeName) as any)
: sortOrderRaw

const sortOrder = useUnique(sortWithOrderingFn)

const {
error,
hasMaxItems,
isLazyLoading,
isLoading,
isSearchReady,
items,
onListChange,
onRetry,
} = useDocumentList({
apiVersion,
filter,
params,
searchQuery: searchQuery?.trim(),
sortOrder,
})
const {error, isLoadingFullList, isLoading, items, fromCache, onLoadFullList, onRetry} =
useDocumentList({
apiVersion,
filter,
params,
searchQuery: searchQuery?.trim(),
sortOrder,
})

const handleQueryChange = useObservableEvent(
(event$: Observable<React.ChangeEvent<HTMLInputElement>>) => {
Expand All @@ -122,30 +133,41 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
[handleClearSearch],
)

useEffect(() => {
if (showSearchLoadingRef.current === false && !isLoading) {
showSearchLoadingRef.current = true
}
const [enableSearchSpinner, setEnableSearchSpinner] = useState<string | void>()

return () => {
showSearchLoadingRef.current = false
useEffect(() => {
if (!enableSearchSpinner && !isLoading) {
setEnableSearchSpinner(paneKey)
}
}, [isLoading])
}, [enableSearchSpinner, isLoading, paneKey])

useEffect(() => {
// Clear search field and reset showSearchLoadingRef ref
// Clear search field and disable search spinner
// when switching between panes (i.e. when paneKey changes).
handleClearSearch()
showSearchLoadingRef.current = false
setEnableSearchSpinner()
}, [paneKey, handleClearSearch])

const loadingVariant: LoadingVariant = useMemo(() => {
const showSpinner = isLoading && items.length === 0 && showSearchLoadingRef.current

if (showSpinner) return 'spinner'
if (isLoading && enableSearchSpinner === paneKey) {
return 'spinner'
}
if (fromCache) {
return 'subtle'
}

return 'initial'
}, [isLoading, items.length])
}, [enableSearchSpinner, fromCache, isLoading, paneKey])

const textInputIcon = useMemo(() => {
if (loadingVariant === 'spinner') {
return AnimatedSpinnerIcon
}
if (searchInputValue && loadingVariant === 'subtle') {
return SubtleSpinnerIcon
}
return SearchIcon
}, [loadingVariant, searchInputValue])

return (
<>
Expand All @@ -155,9 +177,12 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
autoComplete="off"
border={false}
clearButton={Boolean(searchQuery)}
disabled={!isSearchReady}
disabled={Boolean(error)}
fontSize={[2, 2, 1]}
icon={loadingVariant === 'spinner' ? AnimatedSpinnerIcon : SearchIcon}
icon={textInputIcon}
iconRight={
loadingVariant === 'subtle' && !searchInputValue ? DelayedSubtleSpinnerIcon : null
}
onChange={handleQueryChange}
onClear={handleClearSearch}
onKeyDown={handleSearchKeyDown}
Expand All @@ -173,16 +198,16 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
childItemId={childItemId}
error={error}
filterIsSimpleTypeConstraint={!!typeName}
hasMaxItems={hasMaxItems}
hasMaxItems={items.length === FULL_LIST_LIMIT}
hasSearchQuery={Boolean(searchQuery)}
isActive={isActive}
isLazyLoading={isLazyLoading}
isLazyLoading={isLoadingFullList}
isLoading={isLoading}
items={items}
key={paneKey}
layout={layout}
loadingVariant={loadingVariant}
onListChange={onListChange}
onEndReached={onLoadFullList}
onRetry={onRetry}
paneTitle={title}
searchInputElement={searchInputElement}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {structureLocaleNamespace} from '../../i18n'
import {FULL_LIST_LIMIT} from './constants'
import {type DocumentListPaneItem, type LoadingVariant} from './types'

const RootBox = styled(Box)`
const RootBox = styled(Box)<{$opacity?: number}>`
position: relative;
opacity: ${(props) => props.$opacity || 1};
transition: opacity 0.4s;
`

const CommandListBox = styled(Box)`
Expand All @@ -44,7 +46,7 @@ interface DocumentListPaneContentProps {
items: DocumentListPaneItem[]
layout?: GeneralPreviewLayoutKey
loadingVariant?: LoadingVariant
onListChange: () => void
onEndReached: () => void
onRetry?: () => void
paneTitle: string
searchInputElement: HTMLInputElement | null
Expand Down Expand Up @@ -78,7 +80,7 @@ export function DocumentListPaneContent(props: DocumentListPaneContentProps) {
items,
layout,
loadingVariant,
onListChange,
onEndReached,
onRetry,
paneTitle,
searchInputElement,
Expand All @@ -89,14 +91,14 @@ export function DocumentListPaneContent(props: DocumentListPaneContentProps) {

const {collapsed: layoutCollapsed} = usePaneLayout()
const {collapsed, index} = usePane()
const [shouldRender, setShouldRender] = useState(false)
const [shouldRender, setShouldRender] = useState(!collapsed)
const {t} = useTranslation(structureLocaleNamespace)

const handleEndReached = useCallback(() => {
if (isLoading || isLazyLoading || !shouldRender) return

onListChange()
}, [isLazyLoading, isLoading, onListChange, shouldRender])
if (shouldRender) {
onEndReached()
}
}, [onEndReached, shouldRender])

useEffect(() => {
if (collapsed) return undefined
Expand Down Expand Up @@ -224,7 +226,7 @@ export function DocumentListPaneContent(props: DocumentListPaneContentProps) {
const key = `${index}-${collapsed}`

return (
<RootBox overflow="hidden" height="fill">
<RootBox overflow="hidden" height="fill" $opacity={loadingVariant === 'subtle' ? 0.8 : 1}>
<CommandListBox>
<CommandList
activeItemDataAttr="data-hovered"
Expand Down
2 changes: 2 additions & 0 deletions packages/sanity/src/structure/panes/documentList/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export const PARTIAL_PAGE_LIMIT = 100
export const FULL_LIST_LIMIT = 2000
export const DEFAULT_ORDERING: SortOrder = {by: [{field: '_updatedAt', direction: 'desc'}]}
export const EMPTY_RECORD: Record<string, unknown> = {}

export const ENABLE_LRU_MEMO = true
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import {type SanityClient} from '@sanity/client'
import QuickLRU from 'quick-lru'
import {
asyncScheduler,
defer,
EMPTY,
map,
merge,
mergeMap,
type Observable,
of,
type OperatorFunction,
partition,
pipe,
share,
take,
throttleTime,
throwError,
timer,
} from 'rxjs'
import {tap} from 'rxjs/operators'
import {exhaustMapWithTrailing} from 'rxjs-exhaustmap-with-trailing'
import {createSearch, getSearchableTypes, type SanityDocumentLike, type Schema} from 'sanity'

import {getExtendedProjection} from '../../structureBuilder/util/getExtendedProjection'
// FIXME
// eslint-disable-next-line boundaries/element-types
import {ENABLE_LRU_MEMO} from './constants'
import {type SortOrder} from './types'

interface ListenQueryOptions {
Expand All @@ -35,7 +39,12 @@ interface ListenQueryOptions {
enableLegacySearch?: boolean
}

export function listenSearchQuery(options: ListenQueryOptions): Observable<SanityDocumentLike[]> {
export interface SearchQueryResult {
fromCache: boolean
documents: SanityDocumentLike[]
}

export function listenSearchQuery(options: ListenQueryOptions): Observable<SearchQueryResult> {
const {
client,
schema,
Expand Down Expand Up @@ -82,6 +91,8 @@ export function listenSearchQuery(options: ListenQueryOptions): Observable<Sanit

const [welcome$, mutationAndReconnect$] = partition(events$, (ev) => ev.type === 'welcome')

const memoKey = JSON.stringify({filter, limit, params, searchQuery, sort, staticTypeNames})

return merge(
welcome$.pipe(take(1)),
mutationAndReconnect$.pipe(throttleTime(1000, asyncScheduler, {leading: true, trailing: true})),
Expand Down Expand Up @@ -146,5 +157,37 @@ export function listenSearchQuery(options: ListenQueryOptions): Observable<Sanit
}),
)
}),
ENABLE_LRU_MEMO
? pipe(
memoLRU(memoKey, lru),
map((memo) => ({
fromCache: memo.type === 'memo',
documents: memo.value,
})),
)
: map((documents) => ({
fromCache: false,
documents,
})),
)
}

const lru = new QuickLRU<string, SanityDocumentLike[]>({maxSize: 100})
function memoLRU<T>(
memoKey: string,
cache: QuickLRU<string, T>,
): OperatorFunction<T, {type: 'memo'; value: T} | {type: 'value'; value: T}> {
return (input$: Observable<T>) =>
merge(
defer(() =>
cache.has(memoKey) ? of({type: 'memo' as const, value: cache.get(memoKey)!}) : EMPTY,
),
input$.pipe(
tap((result) => cache.set(memoKey, result)),
map((value) => ({
type: 'value' as const,
value: value,
})),
),
)
}
8 changes: 1 addition & 7 deletions packages/sanity/src/structure/panes/documentList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,4 @@ export type SortOrder = {
extendedProjection?: string
}

export interface QueryResult {
error: {message: string} | null
onRetry?: () => void
result: {documents: SanityDocumentLike[]} | null
}

export type LoadingVariant = 'spinner' | 'initial'
export type LoadingVariant = 'spinner' | 'initial' | 'subtle'
Loading

0 comments on commit c126395

Please sign in to comment.