1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

Mobile: Show loading indicator while loading search results (#11104)

This commit is contained in:
Henry Heino
2024-09-24 07:12:02 -07:00
committed by GitHub
parent f772cc500c
commit 3d42485315
15 changed files with 364 additions and 254 deletions

View File

@@ -712,13 +712,14 @@ packages/app-mobile/components/screens/Note.test.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
packages/app-mobile/components/screens/ShareManager/index.test.js
packages/app-mobile/components/screens/ShareManager/index.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/search.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
@@ -967,6 +968,8 @@ packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js
packages/lib/hooks/usePlugin.js
packages/lib/hooks/usePrevious.js
packages/lib/hooks/useQueuedAsyncEffect.test.js
packages/lib/hooks/useQueuedAsyncEffect.js
packages/lib/htmlUtils.test.js
packages/lib/htmlUtils.js
packages/lib/htmlUtils2.test.js

5
.gitignore vendored
View File

@@ -689,13 +689,14 @@ packages/app-mobile/components/screens/Note.test.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
packages/app-mobile/components/screens/ShareManager/index.test.js
packages/app-mobile/components/screens/ShareManager/index.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/search.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
@@ -944,6 +945,8 @@ packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js
packages/lib/hooks/usePlugin.js
packages/lib/hooks/usePrevious.js
packages/lib/hooks/useQueuedAsyncEffect.test.js
packages/lib/hooks/useQueuedAsyncEffect.js
packages/lib/htmlUtils.test.js
packages/lib/htmlUtils.js
packages/lib/htmlUtils2.test.js

View File

@@ -10,7 +10,6 @@ import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { themeStyle } from '../global-style';
import { OnValueChangedListener } from '../Dropdown';
const { dialogs } = require('../../utils/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;
import { FolderEntity } from '@joplin/lib/services/database/types';
import { State } from '@joplin/lib/reducer';
@@ -26,6 +25,7 @@ import WarningBanner from './WarningBanner';
import WebBetaButton from './WebBetaButton';
import Menu, { MenuOptionType } from './Menu';
import shim from '@joplin/lib/shim';
export { MenuOptionType };
// Rather than applying a padding to the whole bar, it is applied to each
@@ -48,8 +48,6 @@ interface ScreenHeaderProps {
selectedFolderId: string;
notesParentType: string;
noteSelectionEnabled: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
parentComponent: any;
showUndoButton: boolean;
undoButtonDisabled?: boolean;
showRedoButton: boolean;
@@ -544,7 +542,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const folder = await Folder.load(folderId);
const ok = noteIds.length > 1 ? await dialogs.confirm(this.props.parentComponent, _('Move %d notes to notebook "%s"?', noteIds.length, folder.title)) : true;
const ok = noteIds.length > 1 ? await shim.showConfirmationDialog(_('Move %d notes to notebook "%s"?', noteIds.length, folder.title)) : true;
if (!ok) return;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { connect } from 'react-redux';
import NotesScreen from './screens/Notes';
import SearchScreen from './screens/search';
import SearchScreen from './screens/SearchScreen';
import { Component } from 'react';
import { KeyboardAvoidingView, Keyboard, Platform, View, KeyboardEvent, Dimensions, EmitterSubscription } from 'react-native';
import { AppState } from '../utils/types';

View File

@@ -11,10 +11,7 @@ class BaseScreenComponent<Props, State> extends React.Component<Props, State> {
const theme = themeStyle(themeId);
if (rootStyles_[themeId]) return rootStyles_[themeId];
rootStyles_[themeId] = StyleSheet.create({
root: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
root: theme.rootStyle,
});
return rootStyles_[themeId];
}

View File

@@ -29,6 +29,7 @@ export type ThemeStyle = BaseTheme & typeof baseStyle & {
urlText: TextStyle;
headerStyle: TextStyle;
headerWrapperStyle: ViewStyle;
rootStyle: ViewStyle;
keyboardAppearance: 'light'|'dark';
};
@@ -81,6 +82,11 @@ function extraStyles(theme: BaseTheme) {
backgroundColor: theme.headerBackgroundColor,
};
const rootStyle: ViewStyle = {
flex: 1,
backgroundColor: theme.backgroundColor,
};
return {
marginRight: baseStyle.margin,
marginLeft: baseStyle.margin,
@@ -94,6 +100,7 @@ function extraStyles(theme: BaseTheme) {
urlText,
headerStyle,
headerWrapperStyle,
rootStyle,
keyboardAppearance: theme.appearance,
color5: theme.color5 ?? theme.backgroundColor4,

View File

@@ -259,7 +259,6 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
if (!buttonFolderId) buttonFolderId = this.props.activeFolderId;
const addFolderNoteButtons = !!buttonFolderId;
const thisComp = this;
const makeActionButtonComp = () => {
if ((this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) || !Folder.atLeastOneRealFolderExists(this.props.folders)) return null;
@@ -301,7 +300,7 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
inert={accessibilityHidden}
>
<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} />
<ScreenHeader title={iconString + title} showBackButton={false} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} />
<NoteList />
{actionButtonComp}
<DialogBox

View File

@@ -0,0 +1,121 @@
import * as React from 'react';
import { FlatList, View } from 'react-native';
import NoteItem from '../../NoteItem';
import { useEffect, useRef, useState } from 'react';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { NoteEntity } from '@joplin/lib/services/database/types';
import SearchEngineUtils from '@joplin/lib/services/search/SearchEngineUtils';
import Note from '@joplin/lib/models/Note';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { ProgressBar } from 'react-native-paper';
import shim from '@joplin/lib/shim';
interface Props {
query: string;
onHighlightedWordsChange: (highlightedWords: string[])=> void;
ftsEnabled: number;
}
const useResults = (props: Props) => {
const [notes, setNotes] = useState<NoteEntity[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const query = props.query;
const ftsEnabled = props.ftsEnabled;
useQueuedAsyncEffect(async (event) => {
let notes: NoteEntity[] = [];
setIsProcessing(true);
try {
if (query) {
if (ftsEnabled) {
const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true });
notes = r.notes;
} else {
const p = query.split(' ');
const temp = [];
for (let i = 0; i < p.length; i++) {
const t = p[i].trim();
if (!t) continue;
temp.push(t);
}
notes = await Note.previews(null, {
anywherePattern: `*${temp.join('*')}*`,
});
}
}
if (event.cancelled) return;
const parsedQuery = await SearchEngine.instance().parseQuery(query);
const highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
props.onHighlightedWordsChange(highlightedWords);
setNotes(notes);
} finally {
setIsProcessing(false);
}
}, [query, ftsEnabled], { interval: 200 });
return {
notes,
isPending: isProcessing,
};
};
const useIsLongRunning = (isPending: boolean) => {
const [isLongRunning, setIsLongRunning] = useState(false);
const isPendingRef = useRef(isPending);
isPendingRef.current = isPending;
type TimeoutType = ReturnType<typeof shim.setTimeout>;
const timeoutRef = useRef<TimeoutType|null>(null);
useEffect(() => {
if (timeoutRef.current !== null) {
shim.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (isPending) {
const longRunningTimeout = 1000;
timeoutRef.current = shim.setTimeout(() => {
timeoutRef.current = null;
setIsLongRunning(isPendingRef.current);
}, longRunningTimeout);
} else {
setIsLongRunning(false);
}
}, [isPending]);
return isLongRunning;
};
const containerStyle = { flex: 1 };
const SearchResults: React.FC<Props> = props => {
const { notes, isPending } = useResults(props);
// Don't show the progress bar immediately, only show if the search
// is taking some time.
const longRunning = useIsLongRunning(isPending);
// To have the correct height on web, the progress bar needs to be wrapped:
const progressBar = <View>
<ProgressBar indeterminate={true} visible={longRunning}/>
</View>;
return (
<View style={containerStyle}>
{progressBar}
<FlatList
data={notes}
keyExtractor={(item) => item.id}
renderItem={event => <NoteItem note={event.item} />}
/>
</View>
);
};
export default SearchResults;

View File

@@ -0,0 +1,132 @@
import * as React from 'react';
import { StyleSheet, View, TextInput } from 'react-native';
import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import { ThemeStyle, themeStyle } from '../../global-style';
import { AppState } from '../../../utils/types';
import { Dispatch } from 'redux';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import IconButton from '../../IconButton';
import SearchResults from './SearchResults';
interface Props {
themeId: number;
query: string;
visible: boolean;
dispatch: Dispatch;
noteSelectionEnabled: boolean;
ftsEnabled: number;
}
const useStyles = (theme: ThemeStyle) => {
return useMemo(() => {
return StyleSheet.create({
body: {
flex: 1,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.dividerColor,
},
searchTextInput: {
...theme.lineInput,
paddingLeft: theme.marginLeft,
flex: 1,
backgroundColor: theme.backgroundColor,
color: theme.color,
},
clearIcon: {
...theme.icon,
color: theme.colorFaded,
paddingRight: theme.marginRight,
backgroundColor: theme.backgroundColor,
},
});
}, [theme]);
};
const SearchScreenComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme);
const [query, setQuery] = useState(props.query);
const globalQueryRef = useRef(props.query);
globalQueryRef.current = props.query;
useEffect(() => {
if (globalQueryRef.current !== query) {
props.dispatch({
type: 'SEARCH_QUERY',
query,
});
}
}, [props.dispatch, query]);
const clearButton_press = useCallback(() => {
setQuery('');
}, []);
const onHighlightedWordsChange = useCallback((words: string[]) => {
props.dispatch({
type: 'SET_HIGHLIGHTED',
words,
});
}, [props.dispatch]);
return (
<View style={theme.rootStyle}>
<ScreenHeader
title={_('Search')}
folderPickerOptions={{
enabled: props.noteSelectionEnabled,
mustSelect: true,
}}
showSideMenuButton={false}
showSearchButton={false}
/>
<View style={styles.body}>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchTextInput}
autoFocus={props.visible}
underlineColorAndroid="#ffffff00"
onChangeText={setQuery}
value={query}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
<IconButton
themeId={props.themeId}
iconStyle={styles.clearIcon}
iconName='ionicon close-circle'
onPress={clearButton_press}
description={_('Clear')}
/>
</View>
<SearchResults
query={query}
ftsEnabled={props.ftsEnabled}
onHighlightedWordsChange={onHighlightedWordsChange}
/>
</View>
</View>
);
};
const SearchScreen = connect((state: AppState) => {
return {
query: state.searchQuery,
themeId: state.settings.theme,
settings: state.settings,
noteSelectionEnabled: state.noteSelectionEnabled,
ftsEnabled: state.settings['db.ftsEnabled'],
};
})(SearchScreenComponent);
export default SearchScreen;

View File

@@ -56,7 +56,7 @@ function UpgradeSyncTargetScreen(props: any) {
return (
<ScrollView style={{ flex: 1, flexDirection: 'column', backgroundColor: theme.backgroundColor }}>
<ScreenHeader title={_('Sync Target Upgrade')} parentComponent={this} showShouldUpgradeSyncTargetMessage={false} showSearchButton={false} showBackButton={upgradeResult.done}/>
<ScreenHeader title={_('Sync Target Upgrade')} showShouldUpgradeSyncTargetMessage={false} showSearchButton={false} showBackButton={upgradeResult.done}/>
<View style={{ padding: 15, flex: 1 }}>
{renderInProgress()}
{renderDone()}

View File

@@ -1,237 +0,0 @@
import * as React from 'react';
import { StyleSheet, View, TextInput, FlatList, TouchableHighlight } from 'react-native';
import { connect } from 'react-redux';
import ScreenHeader from '../ScreenHeader';
const Icon = require('react-native-vector-icons/Ionicons').default;
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import NoteItem from '../NoteItem';
import { BaseScreenComponent } from '../base-screen';
import { themeStyle } from '../global-style';
const DialogBox = require('react-native-dialogbox').default;
import SearchEngineUtils from '@joplin/lib/services/search/SearchEngineUtils';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { AppState } from '../../utils/types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import { Dispatch } from 'redux';
interface Props {
themeId: number;
query: string;
visible: boolean;
dispatch: Dispatch;
noteSelectionEnabled: boolean;
ftsEnabled: number;
}
interface State {
query: string;
notes: NoteEntity[];
}
class SearchScreenComponent extends BaseScreenComponent<Props, State> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code from before rule was applied.
public dialogbox: any;
private isMounted_ = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private styles_: Record<string, any> = {};
private searchActionQueue_ = new AsyncActionQueue(200);
public static navigationOptions() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { header: null } as any;
}
public constructor(props: Props) {
super(props);
this.state = {
query: '',
notes: [],
};
}
public styles() {
const theme = themeStyle(this.props.themeId);
if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId];
this.styles_ = {};
const styleSheet = StyleSheet.create({
body: {
flex: 1,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.dividerColor,
},
searchTextInput: {
...theme.lineInput,
paddingLeft: theme.marginLeft,
flex: 1,
backgroundColor: theme.backgroundColor,
color: theme.color,
},
clearIcon: {
...theme.icon,
color: theme.colorFaded,
paddingRight: theme.marginRight,
backgroundColor: theme.backgroundColor,
},
});
this.styles_[this.props.themeId] = styleSheet;
return styleSheet;
}
public componentDidMount() {
this.setState({ query: this.props.query });
void this.refreshSearch(this.props.query);
this.isMounted_ = true;
}
public componentWillUnmount() {
this.isMounted_ = false;
}
private clearButton_press() {
this.props.dispatch({
type: 'SEARCH_QUERY',
query: '',
});
this.setState({ query: '' });
void this.refreshSearch('');
}
public async refreshSearch(query: string = null) {
if (!this.props.visible) return;
let notes: NoteEntity[] = [];
if (query) {
if (this.props.ftsEnabled) {
const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true });
notes = r.notes;
} else {
const p = query.split(' ');
const temp = [];
for (let i = 0; i < p.length; i++) {
const t = p[i].trim();
if (!t) continue;
temp.push(t);
}
notes = await Note.previews(null, {
anywherePattern: `*${temp.join('*')}*`,
});
}
}
if (!this.isMounted_) return;
const parsedQuery = await SearchEngine.instance().parseQuery(query);
const highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
this.props.dispatch({
type: 'SET_HIGHLIGHTED',
words: highlightedWords,
});
this.setState({ notes: notes });
}
public scheduleSearch() {
this.searchActionQueue_.push(() => this.refreshSearch(this.state.query));
}
public onComponentWillUnmount() {
void this.searchActionQueue_.reset();
}
private searchTextInput_changeText(text: string) {
this.setState({ query: text });
this.props.dispatch({
type: 'SEARCH_QUERY',
query: text,
});
this.scheduleSearch();
}
public render() {
if (!this.isMounted_) return null;
const theme = themeStyle(this.props.themeId);
const rootStyle = {
flex: 1,
backgroundColor: theme.backgroundColor,
};
if (!this.props.visible) {
rootStyle.flex = 0.001; // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it
}
const thisComponent = this;
return (
<View style={rootStyle}>
<ScreenHeader
title={_('Search')}
parentComponent={thisComponent}
folderPickerOptions={{
enabled: this.props.noteSelectionEnabled,
mustSelect: true,
}}
showSideMenuButton={false}
showSearchButton={false}
/>
<View style={this.styles().body}>
<View style={this.styles().searchContainer}>
<TextInput
style={this.styles().searchTextInput}
autoFocus={this.props.visible}
underlineColorAndroid="#ffffff00"
onChangeText={text => this.searchTextInput_changeText(text)}
value={this.state.query}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
<TouchableHighlight
onPress={() => this.clearButton_press()}
accessibilityLabel={_('Clear')}
>
<Icon name="close-circle" style={this.styles().clearIcon} />
</TouchableHighlight>
</View>
<FlatList data={this.state.notes} keyExtractor={(item) => item.id} renderItem={event => <NoteItem note={event.item} />} />
</View>
<DialogBox
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ref={(dialogbox: any) => {
this.dialogbox = dialogbox;
}}
/>
</View>
);
}
}
const SearchScreen = connect((state: AppState) => {
return {
query: state.searchQuery,
themeId: state.settings.theme,
settings: state.settings,
noteSelectionEnabled: state.noteSelectionEnabled,
ftsEnabled: state.settings['db.ftsEnabled'],
};
})(SearchScreenComponent);
export default SearchScreen;

View File

@@ -61,7 +61,7 @@ import ConfigScreen from './components/screens/ConfigScreen/ConfigScreen';
const { FolderScreen } = require('./components/screens/folder.js');
import LogScreen from './components/screens/LogScreen';
import StatusScreen from './components/screens/status';
import SearchScreen from './components/screens/search';
import SearchScreen from './components/screens/SearchScreen';
const { OneDriveLoginScreen } = require('./components/screens/onedrive-login.js');
import EncryptionConfigScreen from './components/screens/encryption-config';
const { DropboxLoginScreen } = require('./components/screens/dropbox-login.js');

View File

@@ -0,0 +1,35 @@
import type * as React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import useQueuedAsyncEffect from './useQueuedAsyncEffect';
import { runWithFakeTimers } from '../testing/test-utils';
describe('useQueuedAsyncEffect', () => {
test('should debounce effect updates', async () => {
const effectFunction = jest.fn(async () => { });
const useTestHook = (dependencies: React.DependencyList) => {
return useQueuedAsyncEffect(effectFunction, dependencies);
};
await runWithFakeTimers(async () => {
const result = renderHook(useTestHook, { initialProps: ['test'] });
// Should pause to allow debouncing.
expect(effectFunction).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(12500);
expect(effectFunction).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1000);
// Changing twice quickly: Should only update once
result.rerender(['changed']);
expect(effectFunction).toHaveBeenCalledTimes(1);
result.rerender(['changed again']);
await jest.advanceTimersByTimeAsync(500);
expect(effectFunction).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(500);
expect(effectFunction).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,36 @@
import type * as React from 'react';
import shim from '../shim';
import AsyncActionQueue from '../AsyncActionQueue';
const { useEffect, useState } = shim.react();
interface AsyncEffectEvent {
cancelled: boolean;
}
export type EffectFunction = (event: AsyncEffectEvent)=> void|Promise<void>;
export interface Options {
interval?: number;
}
export default (
effect: EffectFunction,
dependencies: React.DependencyList,
{ interval = undefined }: Options = {},
) => {
const [queue] = useState(() => new AsyncActionQueue(interval));
useEffect(() => {
const event: AsyncEffectEvent = { cancelled: false };
queue.push(() => effect(event));
return () => {
event.cancelled = true;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- This is a custom hook
}, dependencies);
useEffect(() => {
return () => {
void queue.reset();
};
}, [queue]);
};

View File

@@ -1092,16 +1092,32 @@ export const mockMobilePlatform = (platform: string) => {
};
};
export const runWithFakeTimers = (callback: ()=> Promise<void>) => {
export const runWithFakeTimers = async (callback: ()=> Promise<void>) => {
if (typeof jest === 'undefined') {
throw new Error('Fake timers are only supported in jest.');
}
jest.useFakeTimers();
// The shim.setTimeout and similar functions need to be changed to
// use fake timers.
const originalSetTimeout = shim.setTimeout;
const originalSetInterval = shim.setInterval;
const originalClearTimeout = shim.clearTimeout;
const originalClearInterval = shim.clearInterval;
shim.setTimeout = setTimeout;
shim.setInterval = setInterval;
shim.clearInterval = clearInterval;
shim.clearTimeout = clearTimeout;
try {
return callback();
return await callback();
} finally {
jest.runOnlyPendingTimers();
shim.setTimeout = originalSetTimeout;
shim.setInterval = originalSetInterval;
shim.clearTimeout = originalClearTimeout;
shim.clearInterval = originalClearInterval;
jest.useRealTimers();
}
};