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

feat: detect unnecessary render and warn user #468

Open
wants to merge 15 commits into
base: v1
Choose a base branch
from

Conversation

guvenkaranfil
Copy link

@guvenkaranfil guvenkaranfil commented Mar 14, 2024

Summary

This draft PR is intended to start providing a way to detect unnecessary renders as described here

Test plan

I have added a test file called UnneccassaryRender.perf-test which is intended to unnecessarily render on the component and see the warning

Screenshot 2024-03-14 at 04 42 53

Copy link

changeset-bot bot commented Mar 14, 2024

⚠️ No Changeset found

Latest commit: 59e0672

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@guvenkaranfil
Copy link
Author

I found something very interesting while testing the implementation @mdjastrzebski

For Unncessary Component test, wrapping state updating inside a timeout cause not finding the unnecessary renders instead of updating directly.

const handlePress = () => { setCount((c) => c + 1); };

with setTimeOut

const handlePress = () => { setTimeout(() => setCount((c) => c + 1), 10); };

@guvenkaranfil
Copy link
Author

guvenkaranfil commented Mar 14, 2024

Another thing I came up with while testing is currently screen variable has been set in the outer scope.

On the first render screen variable is not set yet. On the second onRender callback screen variable is being set. That means first two onRender callbacks we can not do any comparison. So on the third callback can be comparasion because we have screen object and previously saved component representation

I am not sure I am missing any point you have described on the discussion issue @mdjastrzebski

@mdjastrzebski
Copy link
Member

@guvenkaranfil good first step. As a next step you should probably modify measurePerformance function to capture the screen.toJSON() trees (only for RN!) to some array during the first run. Then after the 1st run compare the consecutive JSON trees to find out if they are the same.

@guvenkaranfil
Copy link
Author

@mdjastrzebski I've just updated the implementation based on capturing component representations into an array and compare after.

Then after the 1st run compare the consecutive JSON trees to find out if they are the same.

Why not we would like to do comparison after 1st run instead of like end of the test like hasTooLateRender

@mdjastrzebski
Copy link
Member

@guvenkaranfil The comparison can be made after all runs, that is not that important.

The important factors are:

  1. Gather the toJSON tree during only the first count, and avoid capturing then during any subsequent runs in order not to affect the render times.
  2. The first run is ideal here, as it will usually be discarded (warmupRuns: 1 is the default setup).

Comment on lines 85 to 99
export function isRNTLRunning(): boolean {
return RNTL != null;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function isRNTLRunning(): boolean {
return RNTL != null;
}
export function getTestingLibrary(): boolean {
if (typeof config.testingLibrary === 'string') {
config.testingLibrary.
}
if (RNTL != null) {
return "react-native";
}
if (RTL != null) {
return "react";
}
return null;
}

import { fireEvent, screen } from '@testing-library/react-native';
import { measurePerformance } from 'reassure';

const UnnecessaryComponent = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit]

Suggested change
const UnnecessaryComponent = () => {
const UnnecessaryRendersComponent = () => {

export async function measureRender(ui: React.ReactElement, options?: MeasureOptions): Promise<MeasureResults> {
const runs = options?.runs ?? config.runs;
const scenario = options?.scenario;
const warmupRuns = options?.warmupRuns ?? config.warmupRuns;

const { render, cleanup } = resolveTestingLibrary();
const isCurrentlyRNTL = isRNTLRunning();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const testingLibrary = getTestingLibrary();

Comment on lines 55 to 73
if (isCurrentlyRNTL && screen !== undefined && i === isFirstRun && screen.toJSON()) {
jsonRepresentations.push(JSON.stringify(screen.toJSON()));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (isCurrentlyRNTL && screen !== undefined && i === isFirstRun && screen.toJSON()) {
jsonRepresentations.push(JSON.stringify(screen.toJSON()));
}
if (i === 0 && testingLibrary === 'react-native' && screen != null) {
const json = screen.toJSON();
jsonRepresentations.push(JSON.stringify(json));
}

For work in progress let's keep JSON.stringify. For actual merge perhaps we will add a simple function to compare the trees using DFS (depth-first search) algo.

@@ -55,8 +65,7 @@ export async function measureRender(ui: React.ReactElement, options?: MeasureOpt
};

const uiToRender = buildUiToRender(ui, handleRender, options?.wrapper);
const screen = render(uiToRender);

screen = render(uiToRender);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder: doesn't this generate a race condition in which handleRender is called earlier than the actual render returns?

Pls add logging and check this. Perhaps, for the first render we might need to manually capture screen.toJSON() just after calling render.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this is the part I am so confused. Since handleRender defined and called before screen how we can capture the json 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition, Imagine we have a component that updating state on useEffect.

handleRender will be called but screen will be not initialized yet. So we can not capture the state update even the state update might not effect the UI. For code demonstration;

`const UnnecessaryRendersComponent = () => {
const [name, setname] = React.useState('');

React.useEffect(() => {
setname('Hello');
}, []);

return ;
};`

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const screen = render(...);
if (i === 0 && testingLibrary === 'react-native') {
  const firstJson = screen.toJSON();
}

That should work

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regarding useEffects with no async delay. We would have to test if they happen before initial render returns. It might be so, but that would also by a symptom of a problem, that when component has all the data at hand (i.e. no async operations happen) it is triggering re-render using useEffect instead of rendering it once correctly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next we will do the tree comparison between the final initial render and subsequent deplayed (async) re-renders. These we can simpy call "unnecessary renders" (perhaps you have some better name) and are the original scope of this PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So actually if useEffect contains some sync operations React immediately schedule for re-render operation but async operation React does not schedule a re-render operation it waits for async operation for initially rendering the component. Did I get it correctly 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, async operation will probably end after initial render(s).

To sum up we have to cases when we want to warn users:

  1. More than 1 initial render: this means that they put some useEffect which triggers immediate re-render. From performance perspective it should be transformed into a single render, as it's is not waiting for addtional data (as it is sync).
  2. Two subsequent renders giving the same JSON output. In such case the render did emit the same output and hence could be optimized by improving code, probably be adding memoization, improving hooks subscriptions, etc.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I updated the implementation based on that. As a result we have an object holds both situations.

redundantRender: { initial: number; update: number; };

initial is used for first and update is for second one. We know also put the information to output.json as well. And as comparison we do in Count changes its apply for Redundant Render

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I do have a question for comparing subsequentially I've used pretty-format package. Do you think it is okay or we should write a function as you said earlier which uses dfs to go through and look for match @mdjastrzebski

jsonRepresentations.every((json) => json === jsonRepresentations.at(0))
) {
const testName = expect.getState().currentTestName;
logger.warn(`test "${testName}" has unnecessary renders. Please update your code to avoid unnecessary renders.`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimally we would like to surface this into output JSON file, and subsequently generated MD and screen reports.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! Basically we would like to have reporting like Count changes etc. right 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-03-22 at 09 40 57

I do save the result into output.json now and show the comparison like Count Changes shows

@@ -69,6 +78,19 @@ export async function measureRender(ui: React.ReactElement, options?: MeasureOpt
runResults.push({ duration, count });
}

if (
jsonRepresentations.length >= minRecordToCompare &&
jsonRepresentations.every((json) => json === jsonRepresentations.at(0))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check should compare each pair of subsequent renders results, not each vs the first one

@guvenkaranfil
Copy link
Author

@mdjastrzebski Have you had a chance to look updated parts 🤔

@mdjastrzebski
Copy link
Member

@guvenkaranfil Thanks for your patience. I'll try to check it soon.

test('Unncessary Renders Component', async () => {
const scenario = async () => {
const button = screen.getByText('Action');
await screen.findByText('Count: 0');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THe findBy seems unnecessary to the test.

@@ -25,4 +25,9 @@ export interface MeasureResults {

/** Array of measured render/execution count for each run */
counts: number[];

redundantRender: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
redundantRender: {
redundantRenders: {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would make sense to only add this for measurePerformance functio nand not for measureFunction. Therefore let's have 👍🏻

interface MeasurePerfromanceResults extends MeasureResult {
  redundantRenders: RendundantRendersResults;
}

interface RendundantRendersResults {
  initialRenders: number;
  updates: number;
}

if (hasTooLateRender) {
const testName = expect.getState().currentTestName;
logger.warn(
`test "${testName}" still re-renders after test scenario finished.\n\nPlease update your code to wait for all renders to finish.`
);
}

return processRunResults(runResults, warmupRuns);
return processRunResults(runResults, warmupRuns, components);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of passing compoennt for analysis to processRunResults which is also used by measureFunction lets call function to detect redundant renders before calling it.


showFlagsOutputIfNeeded();

const runResults: RunResult[] = [];
let hasTooLateRender = false;
let hasUnnecessaryRender = false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be no code chaning this value.

I would suggest renaming this to (+ change type to number)

let intialRendersCount = 0'


showFlagsOutputIfNeeded();

const runResults: RunResult[] = [];
let hasTooLateRender = false;
let hasUnnecessaryRender = false;
let components: RenderMeasureResult = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The types does not seem correct.

Suggested change
let components: RenderMeasureResult = [];
type ToJsonTree = ReactTestRendererJSON | ReactTestRendererJSON[] | null;
let renderJsonTrees: ToJsonTree = [];
image


showFlagsOutputIfNeeded();

const runResults: RunResult[] = [];
let hasTooLateRender = false;
let hasUnnecessaryRender = false;
let components: RenderMeasureResult = [];
for (let i = 0; i < runs + warmupRuns; i += 1) {
let duration = 0;
let count = 0;
let isFinished = false;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detect initial rendundant rerenders:

// Count renders happening before `render` returns. More than 1 render means there are redundant renders for things like `useEffect()`.
if (screen == null) {
  intialRendersCount += 1;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I believe we do not need to hold intialRendersCount because in the end I calculate the initial redundant renders by counting undefined values on the json array

@@ -69,14 +81,19 @@ export async function measureRender(ui: React.ReactElement, options?: MeasureOpt
runResults.push({ duration, count });
}

if (hasUnnecessaryRender) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (hasUnnecessaryRender) {
if (initialRenderCount > 1) {

@@ -69,14 +81,19 @@ export async function measureRender(ui: React.ReactElement, options?: MeasureOpt
runResults.push({ duration, count });
}

if (hasUnnecessaryRender) {
const testName = expect.getState().currentTestName;
logger.warn(`test "${testName}" has unnecessary renders. Please update your code to avoid unnecessary renders.`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logger.warn(`test "${testName}" has unnecessary renders. Please update your code to avoid unnecessary renders.`);
logger.warn(`test "${testName}" has ${initialRendersCount - 1} unnecessary initial render(s). Please update your code to avoid unnecessary renders.`);

const formatOptionsZeroIndent = { ...FORMAT_OPTIONS, indent: 0 };
let count = 0;

for (let i = 0; i < components.length - 1; i++) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

Copy link
Member

@mdjastrzebski mdjastrzebski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you are going in the good direction. I've suggested some tweaks to actual format of rendundantRenders object, as well as moving things around a bit.

Next step would be to extend the Markdown and Console reports to include info about rendundant re-renders (both initial and updates).

@guvenkaranfil
Copy link
Author

Hello @mdjastrzebski, thanks for detailed feedback. I will follow up your comments and update accordingly. I believe with latest addition we show redundant render information both initial and update.

Screenshot 2024-03-26 at 18 18 44

@guvenkaranfil
Copy link
Author

@mdjastrzebski Should we separate including redundant render information into markdown by types initial and update as we do right now for the console

Screenshot 2024-03-29 at 06 19 13

@guvenkaranfil
Copy link
Author

Hi @mdjastrzebski, I believe it is better to put init and update. What do you think?

@guvenkaranfil
Copy link
Author

Hi @mdjastrzebski, Have you chance to look latest changes? I'd love to get update finalize the PR. Could you please tell me the steps we would need to take like adding additional tests to verify changes etc.

@mdjastrzebski
Copy link
Member

@guvenkaranfil Thank you for your patience with this PR, I didn't have time to work on it yet. From my perspective, I plan to merge it ~soon (before end of April), after doing final review and probably some minor tweaks by hand. You do not need to make any more changes.

@guvenkaranfil
Copy link
Author

@mdjastrzebski Is there anything to help to forward the PR?

@mdjastrzebski
Copy link
Member

@guvenkaranfil This PR will be merged, I just need to find some spare to review it properly.

@guvenkaranfil
Copy link
Author

@mdjastrzebski Okay thank you just let me know if you need me to update anything. I cant wait to use this feature to use my own projects

@mdjastrzebski
Copy link
Member

Slight progress: rebased to v1 branch, as v1 release is imminent

@mdjastrzebski mdjastrzebski changed the base branch from main to v1 May 3, 2024 10:14
@mdjastrzebski mdjastrzebski marked this pull request as ready for review May 3, 2024 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants