-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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]>
- Loading branch information
Showing
11 changed files
with
294 additions
and
101 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -128,6 +128,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. | |
- Fixes missing exports of `useNotifications`, in PR [#5148](https://github.com/microsoft/BotFramework-WebChat/pull/5148), by [@compulim](https://github.com/compulim) | ||
- 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) | ||
- 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) | ||
- Improved performance for `useActivityWithRenderer`, in PR [#5172](https://github.com/microsoft/BotFramework-WebChat/pull/5172), by [@OEvgeny](https://github.com/OEvgeny) | ||
|
||
### Changed | ||
|
||
|
Binary file added
BIN
+36.4 KB
...rformance-js-batched-renderer-does-not-produce-unnecessary-rerenders-1-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
<!doctype html> | ||
<html lang="en-US"> | ||
<head> | ||
<link href="/assets/index.css" rel="stylesheet" type="text/css" /> | ||
<script crossorigin="anonymous" src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script> | ||
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react.development.js"></script> | ||
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script> | ||
<script crossorigin="anonymous" src="/test-harness.js"></script> | ||
<script crossorigin="anonymous" src="/test-page-object.js"></script> | ||
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script> | ||
<style> | ||
small { | ||
padding-inline: 8px; | ||
} | ||
</style> | ||
</head> | ||
<body> | ||
<main id="webchat"></main> | ||
<script type="text/babel" data-presets="env,stage-3,react"> | ||
const BATCH_SIZE = 5; | ||
|
||
const timesActivityRendered = new Map(); | ||
|
||
function activityRendered() { | ||
return next => (...args) => { | ||
const [{ activity }] = args; | ||
const renderActivity = next(...args) | ||
timesActivityRendered.set(activity.id, (timesActivityRendered.get(activity.id) ?? 0) + 1); | ||
return (...args) => ( | ||
<> | ||
{renderActivity.call ? renderActivity(...args) : renderActivity} | ||
<small> Rendered {timesActivityRendered.get(activity.id)} times</small> | ||
</> | ||
) | ||
} | ||
} | ||
|
||
let shownCount = 0; | ||
async function postMessagesBatch(directLine) { | ||
const promises = []; | ||
const timestamp = new Date().toISOString(); | ||
for (let index = 0; index < BATCH_SIZE; index++) { | ||
promises.push( | ||
// Plain text message isolate dependencies on Markdown. | ||
directLine.emulateIncomingActivity( | ||
{ id: `activity-${shownCount + index}`, text: `Message ${shownCount + index}.`, textFormat: 'plain', type: 'message', timestamp }, | ||
{ skipWait: true } | ||
) | ||
); | ||
} | ||
shownCount += BATCH_SIZE; | ||
|
||
await Promise.all(promises); | ||
await pageConditions.numActivitiesShown(shownCount); | ||
} | ||
|
||
run( | ||
async function () { | ||
const { | ||
WebChat: { ReactWebChat } | ||
} = window; // Imports in UMD fashion. | ||
|
||
const { directLine, store } = testHelpers.createDirectLineEmulator(); | ||
|
||
WebChat.renderWebChat({ directLine, store, activityMiddleware: [activityRendered] }, document.querySelector('main')); | ||
|
||
await pageConditions.uiConnected(); | ||
pageElements.transcript().focus(); | ||
|
||
// WHEN: Adding 10 activities. | ||
await postMessagesBatch(directLine); | ||
await postMessagesBatch(directLine); | ||
|
||
// THEN: Should not re-render activity more than twice. | ||
expect(Math.max(...timesActivityRendered.values())).toEqual(2); | ||
expect(Math.min(...timesActivityRendered.values())).toEqual(1); | ||
|
||
// WHEN: Scroll and clicked on the 5th activity. | ||
const previousTimesActivityRendered = structuredClone(timesActivityRendered) | ||
pageElements.activities()[4].scrollIntoView(); | ||
await host.clickAt(10, 10, pageElements.activities()[4]); | ||
|
||
// THEN: Should focus on the activity. | ||
expect(pageElements.focusedActivity()).toEqual(pageElements.activities()[4]); | ||
|
||
// THEN: Should not re-render. | ||
expect(timesActivityRendered).toEqual(previousTimesActivityRendered); | ||
|
||
// WHEN: The 9th activity received an update. | ||
const timestamp = new Date().toISOString(); | ||
const activity9Renders = timesActivityRendered.get('activity-8'); | ||
await directLine.emulateIncomingActivity( | ||
{ id: `activity-8`, text: `Activity 8 got updated`, textFormat: 'plain', type: 'message', timestamp }, | ||
{ skipWait: true } | ||
); | ||
|
||
// THEN: Should re-render the 9th activity once. | ||
expect(timesActivityRendered.get('activity-8')).toBe(activity9Renders + 1); | ||
// THEN: Should render the updated 9th activity. | ||
pageElements.focusedActivity().scrollIntoView(); | ||
await host.snapshot(); | ||
} | ||
); | ||
</script> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ | ||
|
||
describe('Batched renderer', () => { | ||
test('does not produce unnecessary rerenders', () => runHTML('renderActivity.performance')); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
147 changes: 147 additions & 0 deletions
147
packages/component/src/hooks/internal/useMemoAll.spec.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
/** @jest-environment jsdom */ | ||
/* eslint-disable react/prop-types */ | ||
/* eslint-disable no-undef */ | ||
/* eslint no-magic-numbers: "off" */ | ||
import React from 'react'; | ||
import { render } from 'react-dom'; | ||
import { act } from 'react-dom/test-utils'; | ||
import useMemoize from './useMemoAll'; | ||
|
||
const testHook = fun => { | ||
let state; | ||
const UseComponent = ({ useTest }) => { | ||
useTest(); | ||
return null; | ||
}; | ||
const TestComponent = () => { | ||
state = React.useState(); | ||
const [useTest] = state; | ||
if (useTest) { | ||
return <UseComponent useTest={useTest} />; | ||
} | ||
|
||
return <React.Fragment />; | ||
}; | ||
|
||
const root = document.createElement('div'); | ||
render(<TestComponent />, root); | ||
|
||
return (...args) => { | ||
const [_useTest, setTest] = state; | ||
return new Promise(resolve => { | ||
act(() => { | ||
setTest(() => () => resolve(fun(...args))); | ||
}); | ||
}); | ||
}; | ||
}; | ||
|
||
test('useMemoize should cache result across runs', async () => { | ||
const expensiveSum = jest.fn((x, y) => x + y); | ||
|
||
const render = testHook(doMemoChecks => { | ||
// Start a run, all calls to sum() will be cached. | ||
useMemoize(expensiveSum, sum => doMemoChecks(sum), []); | ||
}); | ||
|
||
await render(sum => { | ||
expect(sum(1, 2)).toBe(3); // Not cached, return 3. | ||
expect(sum(1, 2)).toBe(3); // Cached, return 3. | ||
expect(sum(2, 4)).toBe(6); // Not cached, return 6. | ||
expect(sum(1, 2)).toBe(3); // Cached, return 3. This is cached because it is inside the same run. | ||
}); | ||
expect(expensiveSum).toHaveBeenCalledTimes(2); | ||
|
||
expect(expensiveSum.mock.calls[0]).toEqual([1, 2]); | ||
expect(expensiveSum.mock.calls[1]).toEqual([2, 4]); | ||
|
||
// After the run, 1 + 2 = 3, and 2 + 4 = 6 is cached. | ||
|
||
// Start another run with previous cache | ||
await render(sum => { | ||
expect(sum(1, 2)).toBe(3); // Cached from previous run, return 3. | ||
expect(sum(3, 6)).toBe(9); // Not cached, return 9. | ||
}); | ||
|
||
expect(expensiveSum).toHaveBeenCalledTimes(3); | ||
expect(expensiveSum.mock.calls[2]).toEqual([3, 6]); | ||
|
||
// After the run, only 1 + 2 = 3 and 3 + 6 = 9 is cached. 2 + 4 is dropped. | ||
|
||
// Start another run with previous cache | ||
await render(sum => { | ||
expect(sum(2, 4)).toBe(6); // Not cached, return 6 | ||
}); | ||
|
||
expect(expensiveSum).toHaveBeenCalledTimes(4); | ||
expect(expensiveSum.mock.calls[3]).toEqual([2, 4]); | ||
}); | ||
|
||
test('useMemoize should cache result if deps change', async () => { | ||
const expensiveSum = jest.fn((x, y) => x + y); | ||
|
||
const render = testHook(doMemoChecks => { | ||
// Start a run, all calls to sum() will be cached. | ||
useMemoize(expensiveSum, sum => doMemoChecks(sum), [{}]); | ||
}); | ||
|
||
// Start a run, all calls to sum() will be cached. | ||
await render(sum => { | ||
expect(sum(1, 2)).toBe(3); // Not cached, return 3. | ||
expect(sum(1, 2)).toBe(3); // Cached, return 3. | ||
expect(sum(2, 4)).toBe(6); // Not cached, return 6. | ||
expect(sum(1, 2)).toBe(3); // Cached, return 3. This is cached because it is inside the same run. | ||
}); | ||
|
||
expect(expensiveSum).toHaveBeenCalledTimes(2); | ||
|
||
expect(expensiveSum.mock.calls[0]).toEqual([1, 2]); | ||
expect(expensiveSum.mock.calls[1]).toEqual([2, 4]); | ||
|
||
// After the run, 1 + 2 = 3, and 2 + 4 = 6 is cached. | ||
|
||
// Start another run with previous cache | ||
await render(sum => { | ||
expect(sum(1, 2)).toBe(3); // Cached from previous run, return 3. | ||
expect(sum(3, 6)).toBe(9); // Not cached, return 9. | ||
}); | ||
|
||
expect(expensiveSum).toHaveBeenCalledTimes(3); | ||
expect(expensiveSum.mock.calls[2]).toEqual([3, 6]); | ||
|
||
// After the run, only 1 + 2 = 3 and 3 + 6 = 9 is cached. 2 + 4 is dropped. | ||
|
||
// Start another run with previous cache | ||
await render(sum => { | ||
expect(sum(1, 2)).toBe(3); // Cached from previous run, return 3. | ||
expect(sum(2, 4)).toBe(6); // Not cached, return 6 | ||
}); | ||
|
||
expect(expensiveSum).toHaveBeenCalledTimes(4); | ||
expect(expensiveSum.mock.calls[3]).toEqual([2, 4]); | ||
}); | ||
|
||
test('useMemoize should not share cache across hooks', async () => { | ||
const expensiveSum = jest.fn((x, y) => x + y); | ||
|
||
const render = testHook(doMemoChecks => { | ||
// Start a run, all calls to sum() will be cached. | ||
useMemoize(expensiveSum, sum => doMemoChecks(sum), []); | ||
useMemoize(expensiveSum, sum => doMemoChecks(sum), []); | ||
}); | ||
|
||
// Start a run, all calls to sum() will be cached. | ||
await render(sum => { | ||
expect(sum(1, 2)).toBe(3); // Not cached, return 3. | ||
expect(sum(1, 2)).toBe(3); // Cached, return 3. | ||
expect(sum(2, 4)).toBe(6); // Not cached, return 6. | ||
expect(sum(1, 2)).toBe(3); // Cached, return 3. This is cached because it is inside the same run. | ||
}); | ||
|
||
expect(expensiveSum).toHaveBeenCalledTimes(4); | ||
|
||
expect(expensiveSum.mock.calls[0]).toEqual([1, 2]); | ||
expect(expensiveSum.mock.calls[1]).toEqual([2, 4]); | ||
expect(expensiveSum.mock.calls[2]).toEqual([1, 2]); | ||
expect(expensiveSum.mock.calls[3]).toEqual([2, 4]); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.