Skip to content

Commit e054f40

Browse files
author
William Sedlacek
authored
perf: synchronous updates (#2)
* perf: synchronous updates This refactors `useTextWidth` to make use of `useMemo` so that updates are synchronous. Additionally this moves `getTextWidth` and `getContext` to the module scope so that they do not need to be redeclared on every effect call. * refactor: strict definitions Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * feat!: improve options interface BREAKING CHANGE: This requires `text` OR `ref` to be defined both via the TypeScript interface and at runtime. Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * fix: restore memorization deps Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * style: fix linting errors Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * test: element ref object test Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * test: hook updates Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * refactor: fix strict mode example Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * feat: support undefined text Signed-off-by: wSedlacek <wsedlacekc@gmail.com> * chore: version bump 1.1 Signed-off-by: wSedlacek <wsedlacekc@gmail.com>
1 parent 1a238d1 commit e054f40

5 files changed

Lines changed: 86 additions & 34 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tag0/use-text-width",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "React hook to measure text width",
55
"repository": {
66
"type": "git",

src/__tests__/useTextWidth.test.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import React from 'react';
21
import { renderHook } from '@testing-library/react-hooks';
32
import useTextWidth from '../useTextWidth';
43

54
describe('useTextWidth', () => {
6-
const setup = ({ text, font }: { text: string | string[]; font?: string }) =>
7-
renderHook(() => useTextWidth({ text, font }));
5+
const setup = (initialProps: { text: string | string[]; font?: string }) =>
6+
renderHook((initialProps) => useTextWidth(initialProps), { initialProps });
87

98
test('calculate text width with default font', () => {
109
const { result } = setup({ text: 'Hello world!' });
@@ -20,4 +19,45 @@ describe('useTextWidth', () => {
2019
const { result } = setup({ text: ['foo', 'Hello world!', 'bar'] });
2120
expect(result.current).toBe(81);
2221
});
22+
23+
test('updates results when inputs are changed', () => {
24+
const { result, rerender } = setup({ text: 'bar' });
25+
expect(result.current).toBe(20);
26+
rerender({ text: 'Hello world!' });
27+
expect(result.current).toBe(81);
28+
});
29+
30+
test('calculates text width of existing dom element', () => {
31+
const el = document.createElement('span');
32+
el.textContent = 'Hello World!';
33+
const { result } = renderHook(() => useTextWidth({ ref: { current: el } }));
34+
expect(result.current).toBe(54);
35+
});
36+
37+
test('calculates text width of empty element', () => {
38+
// Getting an element that returns `null` for `textContent`
39+
// isn't easy
40+
const proxy = new Proxy(document.createElement('img'), {
41+
get(el, p) {
42+
if (p === 'textContent') {
43+
return null;
44+
}
45+
46+
return el[p as keyof typeof el];
47+
}
48+
});
49+
50+
const { result } = renderHook(() => useTextWidth({ ref: { current: proxy } }));
51+
expect(result.current).toBe(NaN);
52+
});
53+
54+
test('ref returns NaN when null', () => {
55+
const { result } = renderHook(() => useTextWidth({ ref: { current: null } }));
56+
expect(result.current).toBe(NaN);
57+
});
58+
59+
test('undefined text returns NaN', () => {
60+
const { result } = renderHook(() => useTextWidth({ text: undefined }));
61+
expect(result.current).toBe(NaN);
62+
});
2363
});

src/examples/ref.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
33
import useTextWidth from '../useTextWidth';
44

55
const App = () => {
6-
const ref = useRef();
6+
const ref = useRef(null);
77
const width = useTextWidth({ ref });
88

99
console.log(width);

src/useTextWidth.tsx

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,55 @@
1-
import React, { useEffect, useState } from 'react';
1+
import { RefObject, useMemo } from 'react';
2+
3+
export type useTextWidthTextOptions = {
4+
text: string | string[] | undefined;
5+
font?: string;
6+
};
7+
8+
export type useTextWidthRefOptions = {
9+
ref: RefObject<Element>;
10+
};
211

312
export interface useTextWidthType {
4-
({ text, font, ref }: { text?: string | string[]; font?: string; ref?: any }): number;
13+
(options: useTextWidthTextOptions | useTextWidthRefOptions): number;
514
}
615

7-
const useTextWidth: useTextWidthType = ({ text, ref, font = '16px Times' }) => {
8-
const [width, setWidth] = useState(-1);
16+
const getContext = () => {
17+
const fragment: DocumentFragment = document.createDocumentFragment();
18+
const canvas: HTMLCanvasElement = document.createElement('canvas');
19+
fragment.appendChild(canvas);
20+
return canvas.getContext('2d') as CanvasRenderingContext2D;
21+
};
922

10-
useEffect(() => {
11-
const getContext = () => {
12-
const fragment: DocumentFragment = document.createDocumentFragment();
13-
const canvas: HTMLCanvasElement = document.createElement('canvas');
14-
fragment.appendChild(canvas);
15-
return canvas.getContext('2d');
16-
};
23+
const getTextWidth = (currentText: string | string[], font: string) => {
24+
const context = getContext();
25+
context.font = font;
1726

18-
const getTextWidth = (currentText: string | string[]) => {
19-
const context = getContext();
20-
context.font = font;
27+
if (Array.isArray(currentText)) {
28+
return Math.max(...currentText.map((t) => context.measureText(t).width));
29+
} else {
30+
const metrics = context.measureText(currentText);
31+
return metrics.width;
32+
}
33+
};
2134

22-
if (Array.isArray(currentText)) {
23-
return Math.max(...currentText.map((t) => context.measureText(t).width));
24-
} else {
25-
const metrics = context.measureText(currentText);
26-
return metrics.width;
27-
}
28-
};
35+
const useTextWidth: useTextWidthType = (options) => {
36+
const textOptions = useMemo(() => ('text' in options ? options : undefined), [options]);
37+
const refOptions = useMemo(() => ('ref' in options ? options : undefined), [options]);
2938

30-
if (ref && ref.current) {
39+
return useMemo(() => {
40+
if (refOptions?.ref.current?.textContent) {
3141
const context = getContext();
32-
const computedStyles = window.getComputedStyle(ref.current);
42+
const computedStyles = window.getComputedStyle(refOptions.ref.current);
3343
context.font = computedStyles.font;
34-
const metrics = context.measureText(ref.current.textContent);
35-
setWidth(metrics.width);
36-
} else if (text) {
37-
setWidth(getTextWidth(text));
44+
const metrics = context.measureText(refOptions.ref.current.textContent);
45+
46+
return metrics.width;
47+
} else if (textOptions?.text) {
48+
return getTextWidth(textOptions.text, textOptions.font ?? '16px times');
3849
}
39-
}, [font, ref, text]);
4050

41-
return width;
51+
return NaN;
52+
}, [textOptions?.text, textOptions?.font, refOptions?.ref]);
4253
};
4354

4455
export default useTextWidth;

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"compilerOptions": {
3+
"strict": true,
34
"baseUrl": ".",
45
"rootDir": "src",
56
"outDir": "dist",

0 commit comments

Comments
 (0)