This commit is contained in:
Sergey Garin 2024-06-21 11:33:57 +03:00
parent 83add15830
commit d4d2f9348f
8 changed files with 237 additions and 32 deletions

View File

@ -7,6 +7,9 @@
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as debounceValue from '#/hooks/debounceValueHooks'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as textProvider from '#/providers/TextProvider'
@ -24,6 +27,8 @@ export interface SuspenseProps extends React.SuspenseProps {
readonly offlineFallbackProps?: result.ResultProps
}
const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250
/**
* Suspense is a component that allows you to wrap a part of your application that might suspend,
* showing a fallback to the user while waiting for the data to load.
@ -32,22 +37,52 @@ export interface SuspenseProps extends React.SuspenseProps {
* And handles offline scenarios.
*/
export function Suspense(props: SuspenseProps) {
const { children, loaderProps, fallback, offlineFallbackProps, offlineFallback } = props
const { children } = props
return <React.Suspense fallback={<FallbackElement {...props} />}>{children}</React.Suspense>
}
/**
* Fallback Element
* Checks if ongoing network requests are happening
* And shows either fallback(loader) or offline message
*
* Some request do not require active internet connection, e.g. requests in local backend
* So we don't want to show misleading information
*
* We check the fetching status in fallback component because
* we want to know if there are ongiong requests once React renders the fallback in suspense
*/
function FallbackElement(props: SuspenseProps) {
const { loaderProps, fallback, offlineFallbackProps, offlineFallback } = props
const { getText } = textProvider.useText()
const { isOffline } = offlineHooks.useOffline()
const getFallbackElement = () => {
if (isOffline) {
return (
offlineFallback ?? (
<result.Result status="info" title={getText('offlineTitle')} {...offlineFallbackProps} />
)
)
} else {
return fallback ?? <loader.Loader minHeight="h24" size="medium" {...loaderProps} />
}
}
const paused = reactQuery.useIsFetching({ fetchStatus: 'paused' })
return <React.Suspense fallback={getFallbackElement()}>{children}</React.Suspense>
const fetching = reactQuery.useIsFetching({
predicate: query =>
query.state.fetchStatus === 'fetching' ||
query.state.status === 'pending' ||
query.state.status === 'success',
})
// we use small debounce to avoid flickering when query is resolved,
// but fallback is still showing
const shouldDisplayOfflineMessage = debounceValue.useDebounceValue(
isOffline && paused >= 0 && fetching === 0,
OFFLINE_FETCHING_TOGGLE_DELAY_MS
)
if (shouldDisplayOfflineMessage) {
return (
offlineFallback ?? (
<result.Result status="info" title={getText('offlineTitle')} {...offlineFallbackProps} />
)
)
} else {
return fallback ?? <loader.Loader minHeight="h24" size="medium" {...loaderProps} />
}
}

View File

@ -27,7 +27,7 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
const isUnderPaywall = isFeatureUnderPaywall('share')
return (
<div className="h-table-row flex w-full items-center gap-icon-with-text">
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<ariaComponents.Button
variant="icon"
size="xsmall"

View File

@ -0,0 +1,93 @@
/**
* @file
*
* This file contains the `useDebouncedCallback` hook which is used to debounce a callback function.
*/
import * as React from 'react'
import * as callbackHooks from './eventCallbackHooks'
import * as unmountEffect from './unmountEffectHooks'
/**
* Wrap a callback into debounce function
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useDebouncedCallback<Fn extends (...args: any[]) => any>(
callback: Fn,
deps: React.DependencyList,
delay: number,
maxWait = 0
): DebouncedFunction<Fn> {
const callbackEvent = callbackHooks.useEventCallback(callback)
const timeout = React.useRef<ReturnType<typeof setTimeout>>()
const waitTimeout = React.useRef<ReturnType<typeof setTimeout>>()
const lastCall = React.useRef<{ args: Parameters<Fn>; this: ThisParameterType<Fn> }>()
const clear = () => {
if (timeout.current) {
clearTimeout(timeout.current)
timeout.current = undefined
}
if (waitTimeout.current) {
clearTimeout(waitTimeout.current)
waitTimeout.current = undefined
}
}
// cancel scheduled execution on unmount
unmountEffect.useUnmountEffect(clear)
return React.useMemo(() => {
const execute = () => {
if (!lastCall.current) {
// eslint-disable-next-line no-restricted-syntax
return
}
const context = lastCall.current
lastCall.current = undefined
callbackEvent.apply(context.this, context.args)
clear()
}
// eslint-disable-next-line no-restricted-syntax
const wrapped = function (this, ...args) {
if (timeout.current) {
clearTimeout(timeout.current)
}
lastCall.current = { args, this: this }
if (delay === 0) {
execute()
} else {
// plan regular execution
timeout.current = setTimeout(execute, delay)
// plan maxWait execution if required
if (maxWait > 0 && !waitTimeout.current) {
waitTimeout.current = setTimeout(execute, maxWait)
}
}
} as DebouncedFunction<Fn>
Object.defineProperties(wrapped, {
length: { value: callbackEvent.length },
name: { value: `${callbackEvent.name || 'anonymous'}__debounced__${delay}` },
})
return wrapped
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callbackEvent, delay, maxWait, ...deps])
}
/**
*
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface DebouncedFunction<Fn extends (...args: any[]) => any> {
(this: ThisParameterType<Fn>, ...args: Parameters<Fn>): void
}

View File

@ -0,0 +1,41 @@
/**
* @file
*
* This file contains the `useDebounceState` hook,
* which is a custom hook that returns a stateful value and a function to update it that will debounce updates.
*/
import * as React from 'react'
import * as debouncedCallback from './debounceCallbackHooks'
import * as eventCallbackHooks from './eventCallbackHooks'
/**
* A hook that returns a stateful value, and a function to update it that will debounce updates.
*/
export function useDebounceState<S>(
initialState: S | (() => S),
delay: number,
maxWait = 0
): [S, React.Dispatch<React.SetStateAction<S>>] {
const [state, setState] = React.useState(initialState)
const currentValueRef = React.useRef(state)
const [, startTransition] = React.useTransition()
const dSetState = debouncedCallback.useDebouncedCallback<React.Dispatch<React.SetStateAction<S>>>(
value => {
startTransition(() => {
setState(value)
})
},
[],
delay,
maxWait
)
const setValue = eventCallbackHooks.useEventCallback((next: S | ((currentValue: S) => S)) => {
currentValueRef.current = next instanceof Function ? next(currentValueRef.current) : next
dSetState(currentValueRef.current)
})
return [state, setValue]
}

View File

@ -0,0 +1,19 @@
/**
* @file
*
* This file contains the `useDebounceValue` hook.
*/
import * as debounceState from './debounceStateHooks'
/**
* Debounce a value.
*/
export function useDebounceValue<T>(value: T, delay: number, maxWait?: number) {
const [debouncedValue, setDebouncedValue] = debounceState.useDebounceState(value, delay, maxWait)
if (value !== debouncedValue) {
setDebouncedValue(value)
}
return debouncedValue
}

View File

@ -0,0 +1,16 @@
/**
* @file
*/
import * as React from 'react'
import * as eventCallback from './eventCallbackHooks'
/**
* Calls callback when component is unmounted.
*/
export function useUnmountEffect(callback: () => void) {
// by using `useEventCallback` we can ensure that the callback is stable
const callbackEvent = eventCallback.useEventCallback(callback)
React.useEffect(() => callbackEvent, [callbackEvent])
}

View File

@ -95,24 +95,26 @@ function run(props: Omit<app.AppProps, 'httpClient' | 'portalRoot'>) {
const httpClient = new HttpClient()
const queryClient = reactQueryClientModule.createReactQueryClient()
reactDOM.createRoot(root).render(
<React.StrictMode>
<reactQuery.QueryClientProvider client={queryClient}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense fallback={<LoadingScreen />}>
<App
{...props}
supportsDeepLinks={actuallySupportsDeepLinks}
portalRoot={portalRoot}
httpClient={httpClient}
/>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
React.startTransition(() => {
reactDOM.createRoot(root).render(
<React.StrictMode>
<reactQuery.QueryClientProvider client={queryClient}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense fallback={<LoadingScreen />}>
<App
{...props}
supportsDeepLinks={actuallySupportsDeepLinks}
portalRoot={portalRoot}
httpClient={httpClient}
/>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
<reactQueryDevtools.ReactQueryDevtools />
</reactQuery.QueryClientProvider>
</React.StrictMode>
)
<reactQueryDevtools.ReactQueryDevtools />
</reactQuery.QueryClientProvider>
</React.StrictMode>
)
})
}
/** Global configuration for the {@link App} component. */

View File

@ -76,7 +76,6 @@ export function TermsOfServiceModal() {
// and refetch in the background to check for updates.
...(localVersionHash != null && {
initialData: { hash: localVersionHash },
initialDataUpdatedAt: 0,
}),
select: data => data.hash,
})