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
base: v1
Are you sure you want to change the base?
feat: detect unnecessary render and warn user #468
Conversation
|
I found something very interesting while testing the implementation @mdjastrzebski For
with setTimeOut
|
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 I am not sure I am missing any point you have described on the discussion issue @mdjastrzebski |
@guvenkaranfil good first step. As a next step you should probably modify |
@mdjastrzebski I've just updated the implementation based on capturing component representations into an array and compare after.
Why not we would like to do comparison after 1st run instead of like end of the test like |
@guvenkaranfil The comparison can be made after all runs, that is not that important. The important factors are:
|
export function isRNTLRunning(): boolean { | ||
return RNTL != null; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit]
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(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const testingLibrary = getTestingLibrary();
if (isCurrentlyRNTL && screen !== undefined && i === isFirstRun && screen.toJSON()) { | ||
jsonRepresentations.push(JSON.stringify(screen.toJSON())); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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); |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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 ;
};`
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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:
- 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). - 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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.`); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -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)) |
There was a problem hiding this comment.
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
@mdjastrzebski Have you had a chance to look updated parts 🤔 |
@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'); |
There was a problem hiding this comment.
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.
packages/measure/src/types.ts
Outdated
@@ -25,4 +25,9 @@ export interface MeasureResults { | |||
|
|||
/** Array of measured render/execution count for each run */ | |||
counts: number[]; | |||
|
|||
redundantRender: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
redundantRender: { | |
redundantRenders: { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
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; | ||
|
There was a problem hiding this comment.
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;
}
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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++) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍🏻
There was a problem hiding this 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).
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. |
@mdjastrzebski Should we separate including redundant render information into markdown by types |
Hi @mdjastrzebski, I believe it is better to put |
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. |
@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. |
@mdjastrzebski Is there anything to help to forward the PR? |
@guvenkaranfil This PR will be merged, I just need to find some spare to review it properly. |
@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 |
…nnecessary rendering
…t state and compare changes between states using dfs
….json and show comparation results in the end of the test
0fbea97
to
59e0672
Compare
Slight progress: rebased to v1 branch, as v1 release is imminent |
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