Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EuiTextTruncate] Performance improvements; Remove non-canvas rendering methods #7210

Merged
merged 16 commits into from
Sep 25, 2023
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
36 changes: 21 additions & 15 deletions src-docs/src/views/text_truncate/performance.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { css } from '@emotion/react';
import { throttle } from 'lodash';
import { faker } from '@faker-js/faker';
Expand All @@ -15,14 +15,16 @@ import {
EuiTextTruncate,
} from '../../../../src';

const text = Array.from({ length: 100 }, () => faker.lorem.lines(5));

export default () => {
// Testing toggles
const [canvasRendering, setCanvasRendering] = useState(true);
const measurementRenderAPI = canvasRendering ? 'canvas' : 'dom';
const [virtualization, setVirtualization] = useState(false);
const [throttleMs, setThrottleMs] = useState(100);
const [throttleMs, setThrottleMs] = useState(0);
const [lineCount, setLineCount] = useState(100);

// Number of lines of text to render
const text = useMemo(() => {
return Array.from({ length: lineCount }, () => faker.lorem.lines(5));
}, [lineCount]);

// Width resize observer
const widthRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -37,19 +39,25 @@ export default () => {
}, throttleMs);

const resizeObserver = new ResizeObserver(onObserve);
resizeObserver.observe(widthRef.current);

document.fonts.ready.then(() => {
resizeObserver.observe(widthRef.current!);
});

() => resizeObserver.disconnect();
}, [throttleMs]);

return (
<EuiText>
<EuiFlexGroup alignItems="center">
<EuiSwitch
label="Toggle canvas rendering"
checked={canvasRendering}
onChange={() => setCanvasRendering(!canvasRendering)}
/>
<EuiFlexGroup alignItems="center" gutterSize="xl">
<EuiFormRow label="Lines" display="columnCompressed">
<EuiFieldNumber
value={lineCount}
onChange={(e) => setLineCount(Number(e.target.value))}
style={{ width: 100 }}
compressed
/>
</EuiFormRow>
<EuiSwitch
label="Toggle virtualization"
checked={virtualization}
Expand Down Expand Up @@ -91,7 +99,6 @@ export default () => {
text={text[index]}
truncation="middle"
width={width}
measurementRenderAPI={measurementRenderAPI}
/>
)}
</FixedSizeList>
Expand All @@ -102,7 +109,6 @@ export default () => {
text={text}
truncation="middle"
width={width}
measurementRenderAPI={measurementRenderAPI}
/>
))
)}
Expand Down
47 changes: 19 additions & 28 deletions src-docs/src/views/text_truncate/text_truncate_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ export const TextTruncateExample = {
intro: (
<EuiCallOut iconType="beta" title="Beta development" color="warning">
<strong>EuiTextTruncate</strong> is a beta component that is still
undergoing performance investigations. We would particularly caution
against repeated usage (over 10 usages per page) with long text (over 100
characters) until we've improved{' '}
<Link to="#performance">performance</Link>.
undergoing <Link to="#performance">performance investigation</Link>. We
would particularly caution in high-usage scenarios (e.g. over 500 usages
per page).
</EuiCallOut>
),
sections: [
Expand Down Expand Up @@ -234,17 +233,17 @@ export const TextTruncateExample = {
text: (
<>
<p>
<strong>EuiTextTruncate</strong> uses an extra DOM element under the
<strong>EuiTextTruncate</strong> uses a canvas element under the
hood to manipulate text and calculate whether the text width fits
within the available width. Additionally, by default, the component
will include its own resize observer in order to react to width
changes.
</p>
<p>
These functionalities can cause performance issues if the component
is rendered many times per page, and we would strongly recommend
using caution when doing so. Several escape hatches are available
for performance improvements:
is rendered over hundreds of times per page, and we would strongly
recommend using caution when doing so. Several escape hatches are
available for performance improvements:
</p>
<ol
css={({ euiTheme }) =>
Expand All @@ -259,37 +258,30 @@ export const TextTruncateExample = {
Pass a <EuiCode>width</EuiCode> prop to skip initializing a resize
observer for each component instance. For text within a container
of the same width, we would strongly recommend applying a single
resize observer to the parent container and passing down that
width to all child <strong>EuiTextTruncate</strong>s.
</li>
<li>
Use the <EuiCode>measurementRenderAPI="canvas"</EuiCode> prop to
utilize the Canvas API for text measurement. While this can be
significantly more performant at higher iterations, please do note
that there are minute pixel to subpixel differences in this
rendering method.
resize observer to the parent container and passing that width to
all child <strong>EuiTextTruncate</strong>s. Additionally, you may
want to consider{' '}
<EuiLink href="https://lodash.com/docs/#throttle" target="_blank">
throttling
</EuiLink>{' '}
any resize observers or width-based logic.
</li>
<li>
Strongly consider using{' '}
Use{' '}
<EuiLink
href="https://github.com/bvaughn/react-window"
target="_blank"
>
virtualization
</EuiLink>{' '}
to reduce the number of rendered elements visible at any given
time, or{' '}
<EuiLink href="https://lodash.com/docs/#throttle" target="_blank">
throttling
</EuiLink>{' '}
any resize observers or width-based logic.
time. For over hundreds of instances, this will generally be the
most effective solution for performance or rerender issues.
</li>
<li>
If necessary, consider pulling out the underlying{' '}
<EuiCode>TruncationUtilsForDOM</EuiCode> and{' '}
<EuiCode>TruncationUtilsForCanvas</EuiCode> truncation utils and
re-using the same canvas context or DOM node, as opposed to
repeatedly creating new ones.
<EuiCode>TruncationUtils</EuiCode> and re-using the same canvas
context, as opposed to repeatedly creating new ones.
</li>
</ol>
</>
Expand All @@ -300,7 +292,6 @@ export const TextTruncateExample = {
snippet: `<EuiTextTruncate
text="Hello world"
width={width}
measurementRenderAPI="canvas"
/>`,
},
],
Expand Down
2 changes: 1 addition & 1 deletion src/components/text_truncate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export type {
} from './text_truncate';
export { EuiTextTruncate } from './text_truncate';

export { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils';
export { CanvasTextUtils, TruncationUtils } from './utils';
4 changes: 2 additions & 2 deletions src/components/text_truncate/text_truncate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ describe('EuiTextTruncate', () => {
{...props}
id="text1"
truncation="startEnd"
width={30}
width={20}
/>
<EuiTextTruncate
{...props}
Expand Down Expand Up @@ -336,7 +336,7 @@ describe('EuiTextTruncate', () => {
getTruncatedText().should('have.text', 'Lorem ipsum dolor sit amet, …');

cy.viewport(100, 50);
getTruncatedText().should('have.text', 'Lorem ipsum …');
getTruncatedText().should('have.text', 'Lorem ipsum…');
});
});
});
33 changes: 3 additions & 30 deletions src/components/text_truncate/text_truncate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,9 @@ import { requiredProps } from '../../test';

// Util mocks
const mockEarlyReturn = { checkIfTruncationIsNeeded: () => false };
const mockCleanup = jest.fn();
jest.mock('./utils', () => {
return {
TruncationUtilsWithDOM: jest.fn(() => ({
...mockEarlyReturn,
cleanup: mockCleanup,
})),
TruncationUtilsWithCanvas: jest.fn(() => mockEarlyReturn),
};
});
import { TruncationUtilsWithCanvas } from './utils';
jest.mock('./utils', () => ({
TruncationUtils: jest.fn(() => mockEarlyReturn),
}));

import { EuiTextTruncate } from './text_truncate';

Expand Down Expand Up @@ -61,24 +53,5 @@ describe('EuiTextTruncate', () => {
});
});

describe('render API', () => {
it('calls the DOM cleanup method after each render', () => {
render(<EuiTextTruncate {...props} measurementRenderAPI="dom" />);
expect(mockCleanup).toHaveBeenCalledTimes(1);
});

it('allows switching to canvas rendering via `measurementRenderAPI`', () => {
render(
<EuiTextTruncate
width={100}
text="Canvas test"
measurementRenderAPI="canvas"
/>
);
expect(TruncationUtilsWithCanvas).toHaveBeenCalledTimes(1);
expect(mockCleanup).not.toHaveBeenCalled();
});
});

// We can't unit test the actual truncation logic in JSDOM - see Cypress spec tests instead
});
26 changes: 3 additions & 23 deletions src/components/text_truncate/text_truncate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from '../observer/resize_observer';
import type { CommonProps } from '../common';

import { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils';
import { TruncationUtils } from './utils';
import { euiTextTruncateStyles as styles } from './text_truncate.styles';

const TRUNCATION_TYPES = ['end', 'start', 'startEnd', 'middle'] as const;
Expand Down Expand Up @@ -85,16 +85,6 @@ export type EuiTextTruncateProps = Omit<
* registers a size change. This callback will **not** fire if `width` is passed.
*/
onResize?: (width: number) => void;
/**
* By default, EuiTextTruncate will calculate its truncation via DOM manipulation
* and measurement, which has the benefit of automatically inheriting font styles.
* However, if this approach proves to have a significant performance impact for your
* usage, consider using the `canvas` API instead, which is more performant.
*
* Please note that there are minute pixel to subpixel differences between the
* two options due to different rendering engines.
*/
measurementRenderAPI?: 'dom' | 'canvas';
/**
* By default, EuiTextTruncate will render the truncated string directly.
* You can optionally pass a render prop function to the component, which
Expand Down Expand Up @@ -129,7 +119,6 @@ const EuiTextTruncateWithWidth: FunctionComponent<
truncationPosition,
ellipsis = '…',
containerRef,
measurementRenderAPI = 'dom',
className,
...rest
}) => {
Expand Down Expand Up @@ -160,16 +149,12 @@ const EuiTextTruncateWithWidth: FunctionComponent<
let truncatedText = '';
if (!containerEl || !width) return truncatedText;

const params = {
const utils = new TruncationUtils({
fullText: text,
ellipsis,
container: containerEl,
availableWidth: width,
};
const utils =
measurementRenderAPI === 'canvas'
? new TruncationUtilsWithCanvas(params)
: new TruncationUtilsWithDOM(params);
});

if (utils.checkIfTruncationIsNeeded() === false) {
truncatedText = text;
Expand All @@ -196,10 +181,6 @@ const EuiTextTruncateWithWidth: FunctionComponent<
break;
}
}

if (measurementRenderAPI === 'dom') {
(utils as TruncationUtilsWithDOM).cleanup();
}
return truncatedText;
}, [
width,
Expand All @@ -209,7 +190,6 @@ const EuiTextTruncateWithWidth: FunctionComponent<
truncationPosition,
ellipsis,
containerEl,
measurementRenderAPI,
]);

const isTruncating = truncatedText !== text;
Expand Down
Loading