Skip to content

Commit

Permalink
[EuiTextTruncate] Performance improvements; Remove non-canvas renderi…
Browse files Browse the repository at this point in the history
…ng methods (#7210)
  • Loading branch information
cee-chen authored Sep 25, 2023
1 parent da9e0e1 commit 020905e
Show file tree
Hide file tree
Showing 10 changed files with 451 additions and 561 deletions.
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

0 comments on commit 020905e

Please sign in to comment.