diff --git a/packages/eui/src/components/delay_render/delay_render.test.tsx b/packages/eui/src/components/delay_render/delay_render.test.tsx new file mode 100644 index 000000000000..7d2af87eb29e --- /dev/null +++ b/packages/eui/src/components/delay_render/delay_render.test.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { act } from '@testing-library/react'; +import { render } from '../../test/rtl'; + +import { EuiDelayRender } from './delay_render'; + +describe('EuiDelayRender', () => { + jest.useFakeTimers(); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('renders its children after the default 500ms delay', () => { + const { container, getByText } = render( + + content + + ); + expect(container).toBeEmptyDOMElement(); + + act(() => { + jest.advanceTimersByTime(499); + }); + expect(container).toBeEmptyDOMElement(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(getByText('content')).toBeInTheDocument(); + }); + + it('respects a custom `delay`', () => { + const { container, getByText } = render( + + content + + ); + + act(() => { + jest.advanceTimersByTime(999); + }); + expect(container).toBeEmptyDOMElement(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(getByText('content')).toBeInTheDocument(); + }); + + it('hides updated children and shows them again after the delay', () => { + const { container, getByText, rerender } = render( + + first + + ); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(getByText('first')).toBeInTheDocument(); + + rerender( + + second + + ); + expect(container).toBeEmptyDOMElement(); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(getByText('second')).toBeInTheDocument(); + }); + + it('never renders updated children before the delay has passed', () => { + // Unlike the DOM assertions above, this catches content being committed + // and immediately hidden again within the same act() flush, which would + // still trigger aria-live announcements + const onRender = jest.fn(); + const Probe = ({ label }: { label: string }) => { + onRender(label); + return {label}; + }; + + const { getByText, rerender } = render( + + + + ); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(getByText('first')).toBeInTheDocument(); + + rerender( + + + + ); + expect(onRender).not.toHaveBeenCalledWith('second'); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(getByText('second')).toBeInTheDocument(); + }); + + it('stays hidden until updates stop arriving for the delay duration', () => { + const { container, getByText, rerender } = render( + + first + + ); + act(() => { + jest.advanceTimersByTime(500); + }); + + rerender( + + second + + ); + act(() => { + jest.advanceTimersByTime(300); + }); + expect(container).toBeEmptyDOMElement(); + + rerender( + + third + + ); + act(() => { + jest.advanceTimersByTime(300); + }); + expect(container).toBeEmptyDOMElement(); + + act(() => { + jest.advanceTimersByTime(200); + }); + expect(getByText('third')).toBeInTheDocument(); + }); + + it('does not re-delay when re-rendered with unchanged children', () => { + const children = stable; + const { getByText, rerender } = render( + {children} + ); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(getByText('stable')).toBeInTheDocument(); + + rerender({children}); + expect(getByText('stable')).toBeInTheDocument(); + }); + + it('clears the pending timeout on unmount', () => { + const { unmount } = render( + + content + + ); + expect(jest.getTimerCount()).toBe(1); + + unmount(); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/eui/src/components/delay_render/delay_render.tsx b/packages/eui/src/components/delay_render/delay_render.tsx index d4c2ad452baa..2e9a462c305f 100644 --- a/packages/eui/src/components/delay_render/delay_render.tsx +++ b/packages/eui/src/components/delay_render/delay_render.tsx @@ -6,66 +6,36 @@ * Side Public License, v 1. */ -import { Component, PropsWithChildren } from 'react'; +import { + FunctionComponent, + PropsWithChildren, + useEffect, + useState, +} from 'react'; export interface EuiDelayRenderProps extends PropsWithChildren { - delay: number; + delay?: number; } -interface EuiDelayRenderState { - toggle: boolean; -} - -export class EuiDelayRender extends Component< - EuiDelayRenderProps, - EuiDelayRenderState -> { - static defaultProps = { - delay: 500, - }; - - private delayID: number | undefined; - private toBeDelayed: boolean = true; - - constructor(props: EuiDelayRenderProps) { - super(props); - this.state = { - toggle: false, - }; +export const EuiDelayRender: FunctionComponent = ({ + delay = 500, + children, +}) => { + const [shouldRender, setShouldRender] = useState(false); + const [prevProps, setPrevProps] = useState({ children, delay }); + + // Hiding must happen during the render phase: waiting for an effect would + // briefly commit updated children before hiding them again, flashing the + // content and triggering any aria-live announcements it may contain + if (children !== prevProps.children || delay !== prevProps.delay) { + setPrevProps({ children, delay }); + setShouldRender(false); } - shouldUpdate() { - this.setState(({ toggle }) => ({ toggle: !toggle })); - } - - startDelaying = () => { - window.clearTimeout(this.delayID); - this.toBeDelayed = true; - this.delayID = window.setTimeout(this.stopDelaying, this.props.delay); - }; - stopDelaying = () => { - window.clearTimeout(this.delayID); - this.toBeDelayed = false; - this.shouldUpdate(); - }; + useEffect(() => { + const timeoutId = window.setTimeout(() => setShouldRender(true), delay); + return () => window.clearTimeout(timeoutId); + }, [children, delay]); - componentDidMount() { - this.startDelaying(); - } - shouldComponentUpdate() { - if (this.toBeDelayed) { - this.startDelaying(); - } - return true; - } - componentWillUnmount() { - this.stopDelaying(); - } - componentDidUpdate() { - this.toBeDelayed = true; - } - - render() { - return !this.toBeDelayed ? this.props.children : null; - } -} + return shouldRender ? children : null; +};