Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions packages/eui/src/components/delay_render/delay_render.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<EuiDelayRender>
<span>content</span>
</EuiDelayRender>
);
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(
<EuiDelayRender delay={1000}>
<span>content</span>
</EuiDelayRender>
);

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(
<EuiDelayRender>
<span>first</span>
</EuiDelayRender>
);
act(() => {
jest.advanceTimersByTime(500);
});
expect(getByText('first')).toBeInTheDocument();

rerender(
<EuiDelayRender>
<span>second</span>
</EuiDelayRender>
);
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 <span>{label}</span>;
};

const { getByText, rerender } = render(
<EuiDelayRender>
<Probe label="first" />
</EuiDelayRender>
);
act(() => {
jest.advanceTimersByTime(500);
});
expect(getByText('first')).toBeInTheDocument();

rerender(
<EuiDelayRender>
<Probe label="second" />
</EuiDelayRender>
);
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(
<EuiDelayRender>
<span>first</span>
</EuiDelayRender>
);
act(() => {
jest.advanceTimersByTime(500);
});

rerender(
<EuiDelayRender>
<span>second</span>
</EuiDelayRender>
);
act(() => {
jest.advanceTimersByTime(300);
});
expect(container).toBeEmptyDOMElement();

rerender(
<EuiDelayRender>
<span>third</span>
</EuiDelayRender>
);
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 = <span>stable</span>;
const { getByText, rerender } = render(
<EuiDelayRender>{children}</EuiDelayRender>
);
act(() => {
jest.advanceTimersByTime(500);
});
expect(getByText('stable')).toBeInTheDocument();

rerender(<EuiDelayRender>{children}</EuiDelayRender>);
expect(getByText('stable')).toBeInTheDocument();
});

it('clears the pending timeout on unmount', () => {
const { unmount } = render(
<EuiDelayRender>
<span>content</span>
</EuiDelayRender>
);
expect(jest.getTimerCount()).toBe(1);

unmount();
expect(jest.getTimerCount()).toBe(0);
});
});
82 changes: 26 additions & 56 deletions packages/eui/src/components/delay_render/delay_render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EuiDelayRenderProps> = ({
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;
};
Loading