-
Notifications
You must be signed in to change notification settings - Fork 8.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[APM] Reduce loading indicator flickering (#34701)
* [APM] Reduce progress bar flickering * Make private methods private * Fix eslint issue with console.log
- Loading branch information
Showing
9 changed files
with
325 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
x-pack/plugins/apm/public/components/app/Main/useDelayedVisibility/Delayed/index.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { Delayed } from '.'; | ||
|
||
// Advanced time like setTimeout and mocks Date.now() to stay in sync | ||
class AdvanceTimer { | ||
public nowTime = 0; | ||
public advance(ms: number) { | ||
this.nowTime += ms; | ||
jest.spyOn(Date, 'now').mockReturnValue(this.nowTime); | ||
jest.advanceTimersByTime(ms); | ||
} | ||
} | ||
|
||
describe('Delayed', () => { | ||
it('should not flicker between show/hide when the hide interval is very short', async () => { | ||
jest.useFakeTimers(); | ||
const visibilityChanges: boolean[] = []; | ||
const advanceTimer = new AdvanceTimer(); | ||
const delayed = new Delayed(); | ||
|
||
delayed.onChange(isVisible => visibilityChanges.push(isVisible)); | ||
|
||
for (let i = 1; i < 100; i += 2) { | ||
delayed.show(); | ||
advanceTimer.advance(1000); | ||
delayed.hide(); | ||
advanceTimer.advance(20); | ||
} | ||
advanceTimer.advance(100); | ||
|
||
expect(visibilityChanges).toEqual([true, false]); | ||
}); | ||
|
||
it('should not be shown at all when the duration is very short', async () => { | ||
jest.useFakeTimers(); | ||
const advanceTimer = new AdvanceTimer(); | ||
const visibilityChanges: boolean[] = []; | ||
const delayed = new Delayed(); | ||
|
||
delayed.onChange(isVisible => visibilityChanges.push(isVisible)); | ||
|
||
delayed.show(); | ||
advanceTimer.advance(30); | ||
delayed.hide(); | ||
advanceTimer.advance(1000); | ||
|
||
expect(visibilityChanges).toEqual([]); | ||
}); | ||
|
||
it('should be displayed for minimum 1000ms', async () => { | ||
jest.useFakeTimers(); | ||
const visibilityChanges: boolean[] = []; | ||
const advanceTimer = new AdvanceTimer(); | ||
const delayed = new Delayed(); | ||
|
||
delayed.onChange(isVisible => visibilityChanges.push(isVisible)); | ||
|
||
delayed.show(); | ||
advanceTimer.advance(200); | ||
delayed.hide(); | ||
advanceTimer.advance(950); | ||
expect(visibilityChanges).toEqual([true]); | ||
advanceTimer.advance(100); | ||
expect(visibilityChanges).toEqual([true, false]); | ||
delayed.show(); | ||
advanceTimer.advance(50); | ||
expect(visibilityChanges).toEqual([true, false, true]); | ||
delayed.hide(); | ||
advanceTimer.advance(950); | ||
expect(visibilityChanges).toEqual([true, false, true]); | ||
advanceTimer.advance(100); | ||
expect(visibilityChanges).toEqual([true, false, true, false]); | ||
}); | ||
|
||
it('should be displayed for minimum 2000ms', async () => { | ||
jest.useFakeTimers(); | ||
const visibilityChanges: boolean[] = []; | ||
const advanceTimer = new AdvanceTimer(); | ||
const delayed = new Delayed({ minimumVisibleDuration: 2000 }); | ||
|
||
delayed.onChange(isVisible => visibilityChanges.push(isVisible)); | ||
|
||
delayed.show(); | ||
advanceTimer.advance(200); | ||
delayed.hide(); | ||
advanceTimer.advance(1950); | ||
expect(visibilityChanges).toEqual([true]); | ||
advanceTimer.advance(100); | ||
expect(visibilityChanges).toEqual([true, false]); | ||
}); | ||
}); |
60 changes: 60 additions & 0 deletions
60
x-pack/plugins/apm/public/components/app/Main/useDelayedVisibility/Delayed/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
type Callback = (isVisible: boolean) => void; | ||
|
||
export class Delayed { | ||
private displayedAt = 0; | ||
private hideDelayMs: number; | ||
private isVisible = false; | ||
private minimumVisibleDuration: number; | ||
private showDelayMs: number; | ||
private timeoutId?: number; | ||
|
||
constructor({ | ||
minimumVisibleDuration = 1000, | ||
showDelayMs = 50, | ||
hideDelayMs = 50 | ||
} = {}) { | ||
this.minimumVisibleDuration = minimumVisibleDuration; | ||
this.hideDelayMs = hideDelayMs; | ||
this.showDelayMs = showDelayMs; | ||
} | ||
|
||
private onChangeCallback: Callback = () => null; | ||
|
||
private updateState(isVisible: boolean) { | ||
window.clearTimeout(this.timeoutId); | ||
const ms = !isVisible | ||
? Math.max( | ||
this.displayedAt + this.minimumVisibleDuration - Date.now(), | ||
this.hideDelayMs | ||
) | ||
: this.showDelayMs; | ||
|
||
this.timeoutId = window.setTimeout(() => { | ||
if (this.isVisible !== isVisible) { | ||
this.isVisible = isVisible; | ||
this.onChangeCallback(isVisible); | ||
if (isVisible) { | ||
this.displayedAt = Date.now(); | ||
} | ||
} | ||
}, ms); | ||
} | ||
|
||
public show() { | ||
this.updateState(true); | ||
} | ||
|
||
public hide() { | ||
this.updateState(false); | ||
} | ||
|
||
public onChange(onChangeCallback: Callback) { | ||
this.onChangeCallback = onChangeCallback; | ||
} | ||
} |
71 changes: 71 additions & 0 deletions
71
x-pack/plugins/apm/public/components/app/Main/useDelayedVisibility/index.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { cleanup, renderHook } from 'react-hooks-testing-library'; | ||
import { useDelayedVisibility } from '.'; | ||
|
||
afterEach(cleanup); | ||
|
||
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 | ||
/* eslint-disable no-console */ | ||
const originalError = console.error; | ||
beforeAll(() => { | ||
console.error = jest.fn(); | ||
}); | ||
afterAll(() => { | ||
console.error = originalError; | ||
}); | ||
|
||
describe('useFetcher', () => { | ||
let hook; | ||
beforeEach(() => { | ||
jest.useFakeTimers(); | ||
}); | ||
|
||
it('is initially false', () => { | ||
hook = renderHook(isLoading => useDelayedVisibility(isLoading), { | ||
initialProps: false | ||
}); | ||
expect(hook.result.current).toEqual(false); | ||
}); | ||
|
||
it('does not change to true immediately', () => { | ||
hook = renderHook(isLoading => useDelayedVisibility(isLoading), { | ||
initialProps: false | ||
}); | ||
|
||
hook.rerender(true); | ||
jest.advanceTimersByTime(10); | ||
expect(hook.result.current).toEqual(false); | ||
jest.advanceTimersByTime(50); | ||
expect(hook.result.current).toEqual(true); | ||
}); | ||
|
||
it('does not change to false immediately', () => { | ||
hook = renderHook(isLoading => useDelayedVisibility(isLoading), { | ||
initialProps: false | ||
}); | ||
|
||
hook.rerender(true); | ||
jest.advanceTimersByTime(100); | ||
hook.rerender(false); | ||
expect(hook.result.current).toEqual(true); | ||
}); | ||
|
||
it('is true for minimum 1000ms', () => { | ||
hook = renderHook(isLoading => useDelayedVisibility(isLoading), { | ||
initialProps: false | ||
}); | ||
|
||
hook.rerender(true); | ||
jest.advanceTimersByTime(100); | ||
hook.rerender(false); | ||
jest.advanceTimersByTime(900); | ||
expect(hook.result.current).toEqual(true); | ||
jest.advanceTimersByTime(100); | ||
expect(hook.result.current).toEqual(false); | ||
}); | ||
}); |
50 changes: 50 additions & 0 deletions
50
x-pack/plugins/apm/public/components/app/Main/useDelayedVisibility/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { useEffect, useRef, useState } from 'react'; | ||
import { Delayed } from './Delayed'; | ||
|
||
export function useDelayedVisibility( | ||
visible: boolean, | ||
hideDelayMs?: number, | ||
showDelayMs?: number, | ||
minimumVisibleDuration?: number | ||
) { | ||
const [isVisible, setIsVisible] = useState(false); | ||
const delayedRef = useRef<Delayed | null>(null); | ||
|
||
useEffect( | ||
() => { | ||
const delayed = new Delayed({ | ||
hideDelayMs, | ||
showDelayMs, | ||
minimumVisibleDuration | ||
}); | ||
delayed.onChange(visibility => { | ||
setIsVisible(visibility); | ||
}); | ||
delayedRef.current = delayed; | ||
}, | ||
[hideDelayMs, showDelayMs, minimumVisibleDuration] | ||
); | ||
|
||
useEffect( | ||
() => { | ||
if (!delayedRef.current) { | ||
return; | ||
} | ||
|
||
if (visible) { | ||
delayedRef.current.show(); | ||
} else { | ||
delayedRef.current.hide(); | ||
} | ||
}, | ||
[visible] | ||
); | ||
|
||
return isVisible; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.