Skip to content

Commit d3a6f52

Browse files
OEvgenycompulim
andauthored
Perf: allow useMemoAll to cache results independently from deps (#5172)
* Perf: allow useMemoAll to cache results independently from deps * Use more canonical impl for useMemoAll * Rename * Changelog * Self review * Polish * Update packages/component/src/providers/ActivityTree/private/useActivitiesWithRenderer.ts Co-authored-by: William Wong <[email protected]> --------- Co-authored-by: William Wong <[email protected]>
1 parent bf8dbd9 commit d3a6f52

11 files changed

+294
-101
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
128128
- Fixes missing exports of `useNotifications`, in PR [#5148](https://github.com/microsoft/BotFramework-WebChat/pull/5148), by [@compulim](https://github.com/compulim)
129129
- Fixes suggested actions keyboard navigation skips actions after suggested actions got updated, in PR [#5150](https://github.com/microsoft/BotFramework-WebChat/pull/5150), by [@OEvgeny](https://github.com/OEvgeny)
130130
- Fixes [#5155](https://github.com/microsoft/BotFramework-WebChat/issues/5155). Fixed "Super constructor null of anonymous class is not a constructor" error in CDN bundle by bumping to [`[email protected]`](https://www.npmjs.com/package/webpack/v/5.91.0), in PR [#5156](https://github.com/microsoft/BotFramework-WebChat/pull/5156), by [@compulim](https://github.com/compulim)
131+
- Improved performance for `useActivityWithRenderer`, in PR [#5172](https://github.com/microsoft/BotFramework-WebChat/pull/5172), by [@OEvgeny](https://github.com/OEvgeny)
131132

132133
### Changed
133134

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
<script crossorigin="anonymous" src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
6+
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
7+
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>
8+
<script crossorigin="anonymous" src="/test-harness.js"></script>
9+
<script crossorigin="anonymous" src="/test-page-object.js"></script>
10+
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
11+
<style>
12+
small {
13+
padding-inline: 8px;
14+
}
15+
</style>
16+
</head>
17+
<body>
18+
<main id="webchat"></main>
19+
<script type="text/babel" data-presets="env,stage-3,react">
20+
const BATCH_SIZE = 5;
21+
22+
const timesActivityRendered = new Map();
23+
24+
function activityRendered() {
25+
return next => (...args) => {
26+
const [{ activity }] = args;
27+
const renderActivity = next(...args)
28+
timesActivityRendered.set(activity.id, (timesActivityRendered.get(activity.id) ?? 0) + 1);
29+
return (...args) => (
30+
<>
31+
{renderActivity.call ? renderActivity(...args) : renderActivity}
32+
<small> Rendered {timesActivityRendered.get(activity.id)} times</small>
33+
</>
34+
)
35+
}
36+
}
37+
38+
let shownCount = 0;
39+
async function postMessagesBatch(directLine) {
40+
const promises = [];
41+
const timestamp = new Date().toISOString();
42+
for (let index = 0; index < BATCH_SIZE; index++) {
43+
promises.push(
44+
// Plain text message isolate dependencies on Markdown.
45+
directLine.emulateIncomingActivity(
46+
{ id: `activity-${shownCount + index}`, text: `Message ${shownCount + index}.`, textFormat: 'plain', type: 'message', timestamp },
47+
{ skipWait: true }
48+
)
49+
);
50+
}
51+
shownCount += BATCH_SIZE;
52+
53+
await Promise.all(promises);
54+
await pageConditions.numActivitiesShown(shownCount);
55+
}
56+
57+
run(
58+
async function () {
59+
const {
60+
WebChat: { ReactWebChat }
61+
} = window; // Imports in UMD fashion.
62+
63+
const { directLine, store } = testHelpers.createDirectLineEmulator();
64+
65+
WebChat.renderWebChat({ directLine, store, activityMiddleware: [activityRendered] }, document.querySelector('main'));
66+
67+
await pageConditions.uiConnected();
68+
pageElements.transcript().focus();
69+
70+
// WHEN: Adding 10 activities.
71+
await postMessagesBatch(directLine);
72+
await postMessagesBatch(directLine);
73+
74+
// THEN: Should not re-render activity more than twice.
75+
expect(Math.max(...timesActivityRendered.values())).toEqual(2);
76+
expect(Math.min(...timesActivityRendered.values())).toEqual(1);
77+
78+
// WHEN: Scroll and clicked on the 5th activity.
79+
const previousTimesActivityRendered = structuredClone(timesActivityRendered)
80+
pageElements.activities()[4].scrollIntoView();
81+
await host.clickAt(10, 10, pageElements.activities()[4]);
82+
83+
// THEN: Should focus on the activity.
84+
expect(pageElements.focusedActivity()).toEqual(pageElements.activities()[4]);
85+
86+
// THEN: Should not re-render.
87+
expect(timesActivityRendered).toEqual(previousTimesActivityRendered);
88+
89+
// WHEN: The 9th activity received an update.
90+
const timestamp = new Date().toISOString();
91+
const activity9Renders = timesActivityRendered.get('activity-8');
92+
await directLine.emulateIncomingActivity(
93+
{ id: `activity-8`, text: `Activity 8 got updated`, textFormat: 'plain', type: 'message', timestamp },
94+
{ skipWait: true }
95+
);
96+
97+
// THEN: Should re-render the 9th activity once.
98+
expect(timesActivityRendered.get('activity-8')).toBe(activity9Renders + 1);
99+
// THEN: Should render the updated 9th activity.
100+
pageElements.focusedActivity().scrollIntoView();
101+
await host.snapshot();
102+
}
103+
);
104+
</script>
105+
</body>
106+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */
2+
3+
describe('Batched renderer', () => {
4+
test('does not produce unnecessary rerenders', () => runHTML('renderActivity.performance'));
5+
});

packages/component/src/Activity/CarouselLayout.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010

1111
import classNames from 'classnames';
1212
import PropTypes from 'prop-types';
13-
import React, { useMemo } from 'react';
13+
import React, { memo, useMemo } from 'react';
1414

1515
import CarouselFilmStrip from './CarouselFilmStrip';
1616
import useNonce from '../hooks/internal/useNonce';
@@ -117,4 +117,4 @@ CarouselLayout.propTypes = {
117117
...CarouselLayoutCore.propTypes
118118
};
119119

120-
export default CarouselLayout;
120+
export default memo(CarouselLayout);

packages/component/src/Activity/StackedLayout.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { hooks } from 'botframework-webchat-api';
44
import classNames from 'classnames';
55
import PropTypes from 'prop-types';
6-
import React from 'react';
6+
import React, { memo } from 'react';
77

88
import Bubble from './Bubble';
99
import isZeroOrPositive from '../Utils/isZeroOrPositive';
@@ -244,4 +244,4 @@ StackedLayout.propTypes = {
244244
showCallout: PropTypes.bool
245245
};
246246

247-
export default StackedLayout;
247+
export default memo(StackedLayout);

packages/component/src/Transcript/ActivityRow.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { hooks } from 'botframework-webchat-api';
22
import classNames from 'classnames';
33
import PropTypes from 'prop-types';
4-
import React, { forwardRef, useCallback, useRef } from 'react';
4+
import React, { forwardRef, memo, useCallback, useRef } from 'react';
55

66
import { android } from '../Utils/detectBrowser';
77
import FocusTrap from './FocusTrap';
@@ -121,4 +121,4 @@ ActivityRow.propTypes = {
121121
children: PropTypes.any
122122
};
123123

124-
export default ActivityRow;
124+
export default memo(ActivityRow);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/** @jest-environment jsdom */
2+
/* eslint-disable react/prop-types */
3+
/* eslint-disable no-undef */
4+
/* eslint no-magic-numbers: "off" */
5+
import React from 'react';
6+
import { render } from 'react-dom';
7+
import { act } from 'react-dom/test-utils';
8+
import useMemoize from './useMemoAll';
9+
10+
const testHook = fun => {
11+
let state;
12+
const UseComponent = ({ useTest }) => {
13+
useTest();
14+
return null;
15+
};
16+
const TestComponent = () => {
17+
state = React.useState();
18+
const [useTest] = state;
19+
if (useTest) {
20+
return <UseComponent useTest={useTest} />;
21+
}
22+
23+
return <React.Fragment />;
24+
};
25+
26+
const root = document.createElement('div');
27+
render(<TestComponent />, root);
28+
29+
return (...args) => {
30+
const [_useTest, setTest] = state;
31+
return new Promise(resolve => {
32+
act(() => {
33+
setTest(() => () => resolve(fun(...args)));
34+
});
35+
});
36+
};
37+
};
38+
39+
test('useMemoize should cache result across runs', async () => {
40+
const expensiveSum = jest.fn((x, y) => x + y);
41+
42+
const render = testHook(doMemoChecks => {
43+
// Start a run, all calls to sum() will be cached.
44+
useMemoize(expensiveSum, sum => doMemoChecks(sum), []);
45+
});
46+
47+
await render(sum => {
48+
expect(sum(1, 2)).toBe(3); // Not cached, return 3.
49+
expect(sum(1, 2)).toBe(3); // Cached, return 3.
50+
expect(sum(2, 4)).toBe(6); // Not cached, return 6.
51+
expect(sum(1, 2)).toBe(3); // Cached, return 3. This is cached because it is inside the same run.
52+
});
53+
expect(expensiveSum).toHaveBeenCalledTimes(2);
54+
55+
expect(expensiveSum.mock.calls[0]).toEqual([1, 2]);
56+
expect(expensiveSum.mock.calls[1]).toEqual([2, 4]);
57+
58+
// After the run, 1 + 2 = 3, and 2 + 4 = 6 is cached.
59+
60+
// Start another run with previous cache
61+
await render(sum => {
62+
expect(sum(1, 2)).toBe(3); // Cached from previous run, return 3.
63+
expect(sum(3, 6)).toBe(9); // Not cached, return 9.
64+
});
65+
66+
expect(expensiveSum).toHaveBeenCalledTimes(3);
67+
expect(expensiveSum.mock.calls[2]).toEqual([3, 6]);
68+
69+
// After the run, only 1 + 2 = 3 and 3 + 6 = 9 is cached. 2 + 4 is dropped.
70+
71+
// Start another run with previous cache
72+
await render(sum => {
73+
expect(sum(2, 4)).toBe(6); // Not cached, return 6
74+
});
75+
76+
expect(expensiveSum).toHaveBeenCalledTimes(4);
77+
expect(expensiveSum.mock.calls[3]).toEqual([2, 4]);
78+
});
79+
80+
test('useMemoize should cache result if deps change', async () => {
81+
const expensiveSum = jest.fn((x, y) => x + y);
82+
83+
const render = testHook(doMemoChecks => {
84+
// Start a run, all calls to sum() will be cached.
85+
useMemoize(expensiveSum, sum => doMemoChecks(sum), [{}]);
86+
});
87+
88+
// Start a run, all calls to sum() will be cached.
89+
await render(sum => {
90+
expect(sum(1, 2)).toBe(3); // Not cached, return 3.
91+
expect(sum(1, 2)).toBe(3); // Cached, return 3.
92+
expect(sum(2, 4)).toBe(6); // Not cached, return 6.
93+
expect(sum(1, 2)).toBe(3); // Cached, return 3. This is cached because it is inside the same run.
94+
});
95+
96+
expect(expensiveSum).toHaveBeenCalledTimes(2);
97+
98+
expect(expensiveSum.mock.calls[0]).toEqual([1, 2]);
99+
expect(expensiveSum.mock.calls[1]).toEqual([2, 4]);
100+
101+
// After the run, 1 + 2 = 3, and 2 + 4 = 6 is cached.
102+
103+
// Start another run with previous cache
104+
await render(sum => {
105+
expect(sum(1, 2)).toBe(3); // Cached from previous run, return 3.
106+
expect(sum(3, 6)).toBe(9); // Not cached, return 9.
107+
});
108+
109+
expect(expensiveSum).toHaveBeenCalledTimes(3);
110+
expect(expensiveSum.mock.calls[2]).toEqual([3, 6]);
111+
112+
// After the run, only 1 + 2 = 3 and 3 + 6 = 9 is cached. 2 + 4 is dropped.
113+
114+
// Start another run with previous cache
115+
await render(sum => {
116+
expect(sum(1, 2)).toBe(3); // Cached from previous run, return 3.
117+
expect(sum(2, 4)).toBe(6); // Not cached, return 6
118+
});
119+
120+
expect(expensiveSum).toHaveBeenCalledTimes(4);
121+
expect(expensiveSum.mock.calls[3]).toEqual([2, 4]);
122+
});
123+
124+
test('useMemoize should not share cache across hooks', async () => {
125+
const expensiveSum = jest.fn((x, y) => x + y);
126+
127+
const render = testHook(doMemoChecks => {
128+
// Start a run, all calls to sum() will be cached.
129+
useMemoize(expensiveSum, sum => doMemoChecks(sum), []);
130+
useMemoize(expensiveSum, sum => doMemoChecks(sum), []);
131+
});
132+
133+
// Start a run, all calls to sum() will be cached.
134+
await render(sum => {
135+
expect(sum(1, 2)).toBe(3); // Not cached, return 3.
136+
expect(sum(1, 2)).toBe(3); // Cached, return 3.
137+
expect(sum(2, 4)).toBe(6); // Not cached, return 6.
138+
expect(sum(1, 2)).toBe(3); // Cached, return 3. This is cached because it is inside the same run.
139+
});
140+
141+
expect(expensiveSum).toHaveBeenCalledTimes(4);
142+
143+
expect(expensiveSum.mock.calls[0]).toEqual([1, 2]);
144+
expect(expensiveSum.mock.calls[1]).toEqual([2, 4]);
145+
expect(expensiveSum.mock.calls[2]).toEqual([1, 2]);
146+
expect(expensiveSum.mock.calls[3]).toEqual([2, 4]);
147+
});

packages/component/src/hooks/internal/useMemoize.ts packages/component/src/hooks/internal/useMemoAll.ts

+15-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { DependencyList, useMemo } from 'react';
1+
import { useMemo, useRef, type DependencyList } from 'react';
2+
import { useRefFrom } from 'use-ref-from';
23

34
type Cache<TArgs, TResult> = { args: TArgs[]; result: TResult };
45
type Fn<TArgs, TResult> = (...args: TArgs[]) => TResult;
@@ -8,11 +9,13 @@ type Fn<TArgs, TResult> = (...args: TArgs[]) => TResult;
89
*
910
* This is similar to `useMemo`. But instead of calling it once, `useMemoize` enables multiple calls while the `callback` function is executed.
1011
*
12+
* We store cache outside of the memo, so that even in case when dependencies change we're able to use the previous cache for subsequent invocations
13+
*
1114
* @param {Fn<TArgs, TIntermediate>} fn - The function to be memoized.
1215
* @param {(fn: Fn<TArgs, TIntermediate>) => TFinal} callback - When called, this function should execute the memoizing function.
1316
* @param {DependencyList[]} deps - Dependencies to detect for chagnes.
1417
*/
15-
export default function useMemoize<TIntermediate, TFinal>(
18+
export default function useMemoAll<TIntermediate, TFinal>(
1619
fn: Fn<unknown, TIntermediate>,
1720
callback: (fn: Fn<unknown, TIntermediate>) => TFinal,
1821
deps: DependencyList[]
@@ -25,10 +28,13 @@ export default function useMemoize<TIntermediate, TFinal>(
2528
throw new Error('The third argument must be an array.');
2629
}
2730

28-
const memoizedFn = useMemo(() => {
29-
let cache: Cache<unknown, TIntermediate>[] = [];
31+
const fnRef = useRefFrom<Fn<unknown, TIntermediate>>(fn);
32+
const cacheRef = useRef<Cache<unknown, TIntermediate>[]>([]);
3033

31-
return (run: (fn: Fn<unknown, TIntermediate>) => TFinal) => {
34+
const memoizedFn = useMemo(
35+
() => (run: (fn: Fn<unknown, TIntermediate>) => TFinal) => {
36+
const { current: fn } = fnRef;
37+
const { current: cache } = cacheRef;
3238
const nextCache: Cache<unknown, TIntermediate>[] = [];
3339
const result = run((...args) => {
3440
const { result } = [...cache, ...nextCache].find(
@@ -41,13 +47,14 @@ export default function useMemoize<TIntermediate, TFinal>(
4147
return result;
4248
});
4349

44-
cache = nextCache;
50+
cacheRef.current = nextCache;
4551

4652
return result;
47-
};
53+
},
4854
// We are manually creating the deps here. The "callback" arg is also designed not to be impact deps, similar to useEffect(fn), where "fn" is not in deps.
4955
/* eslint-disable-next-line react-hooks/exhaustive-deps */
50-
}, [fn, ...deps]);
56+
[fnRef, cacheRef, ...deps]
57+
);
5158

5259
return memoizedFn(callback);
5360
}

0 commit comments

Comments
 (0)