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;
+};