From 04be1d42dd1a091507ecd96d2953ddce0101986f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCven=20Karanfil?= <40958989+guvenkaranfil@users.noreply.github.com> Date: Fri, 14 Jun 2024 18:28:25 +0300 Subject: [PATCH] feat: detect unnecessary render and warn user (#468) * feat: detect unnecessary render and warn user feat: capture json representations in an array to compare after for unnecessary rendering fix: get current testingLibrary for comparasion only react-native for now fix: save json representation instead of string for rendered component state and compare changes between states using dfs fix: test name fix: update comparasion function and save compare results into output.json and show comparation results in the end of the test fix: update interface and variable names based on pr recommendatations chore: rebase to v1 * refactor: improve output formatting * refactor: fix typo * refactor: remove unnecessary warning * refactor: improve testing * refactor: improve report criteria * refactor: improve naming * refactor: improve code structure & tests * refactor: tweaks * feat: improve markdown output * refactor: custom tree comparer * refactor: clean up code * refactor: update JSON structure * refactor: use "initial update count" naming * chore: fix lint * chore: improve tests * refactor: tweaks * docs: update * refactor: self code review * docs: tweaks * chore: add changeset * refactor: final tweaks --------- Co-authored-by: Guven Karanfil Co-authored-by: Maciej Jastrzebski --- .changeset/hot-ties-double.md | 8 + docusaurus/docs/methodology.md | 23 ++- packages/compare/src/compare.ts | 8 +- packages/compare/src/output/console.ts | 60 ++++++- packages/compare/src/output/markdown.ts | 60 +++++-- packages/compare/src/type-schemas.ts | 7 + packages/compare/src/types.ts | 2 + packages/compare/src/utils/format.ts | 57 ++++--- packages/compare/src/utils/markdown.ts | 4 + packages/measure/package.json | 3 +- .../src/__tests__/measure-function.test.tsx | 4 +- .../src/__tests__/measure-renders.test.tsx | 158 +++++++++++++++++- packages/measure/src/measure-renders.tsx | 53 +++++- packages/measure/src/output.ts | 4 +- packages/measure/src/redundant-renders.tsx | 30 ++++ packages/measure/src/testing-library.ts | 16 ++ packages/measure/src/types.ts | 20 +++ .../native/src/RedundantRenders.perf-test.tsx | 57 +++++++ yarn.lock | 1 + 19 files changed, 516 insertions(+), 59 deletions(-) create mode 100644 .changeset/hot-ties-double.md create mode 100644 packages/measure/src/redundant-renders.tsx create mode 100644 test-apps/native/src/RedundantRenders.perf-test.tsx diff --git a/.changeset/hot-ties-double.md b/.changeset/hot-ties-double.md new file mode 100644 index 00000000..0a537245 --- /dev/null +++ b/.changeset/hot-ties-double.md @@ -0,0 +1,8 @@ +--- +'reassure': minor +'@callstack/reassure-compare': minor +'@callstack/reassure-measure': minor +'@callstack/reassure-cli': minor +--- + +Detect render issues (initial render updates, redundant renders) diff --git a/docusaurus/docs/methodology.md b/docusaurus/docs/methodology.md index f071d22e..a5f62b20 100644 --- a/docusaurus/docs/methodology.md +++ b/docusaurus/docs/methodology.md @@ -32,10 +32,31 @@ You can refer to our example [GitHub workflow](https://github.com/callstack/reas Markdown report

+### Results categorization + Looking at the example you can notice that test scenarios can be assigned to certain categories: - **Significant Changes To Duration** shows test scenario where the performance change is statistically significant and **should** be looked into as it marks a potential performance loss/improvement -- **Meaningless Changes To Duration** shows test scenarios where the performance change is not stastatistically significant +- **Meaningless Changes To Duration** shows test scenarios where the performance change is not statistically significant - **Changes To Count** shows test scenarios where the render or execution count did change - **Added Scenarios** shows test scenarios which do not exist in the baseline measurements - **Removed Scenarios** shows test scenarios which do not exist in the current measurements + +### Render issues (experimental) + +:::note + +This feature is experimental, and its behavior might change without increasing the major version of the package. + +::: + +Reassure analyses your components' render patterns during the initial test run (usually the warm-up run) to spot signs of potential issues. + +Currently, it's able to inform you about the following types of issues: + +- **Initial updates** informs about the number of updates (= re-renders) that happened immediately (synchronously) after the mount (= initial render). This is most likely caused by `useEffect` hook triggering immediate re-renders using set state. In the optimal case, the initial render should not cause immediate re-renders by itself. Next, renders should be caused by some external source: user action, system event, API call response, timers, etc. + +- **Redundant updates** inform about renders that resulted in the same host element tree as the previous render. After each update, this check inspects the host element structure and compares it to the previous structure. If they are the same, the subsequent render could be avoided as it resulted in no visible change to the user. + - This feature is available only on React Native at this time + - The host element tree comparison ignores references to event handlers. This means that differences in function props (e.g. event handlers) are ignored and only non-function props (e.g. strings, numbers, objects, arrays, etc.) are considered + - The report includes the indices of redundant renders for easier diagnose, 0th render is the mount (initial render), renders 1 and later are updates (re-renders) diff --git a/packages/compare/src/compare.ts b/packages/compare/src/compare.ts index c4f33a08..02601925 100644 --- a/packages/compare/src/compare.ts +++ b/packages/compare/src/compare.ts @@ -151,6 +151,8 @@ function compareResults(current: MeasureResults, baseline: MeasureResults | null } }); + const withCurrent = [...compared, ...added]; + const significant = compared .filter((item) => item.isDurationDiffSignificant) .sort((a, b) => b.durationDiff - a.durationDiff); @@ -160,6 +162,9 @@ function compareResults(current: MeasureResults, baseline: MeasureResults | null const countChanged = compared .filter((item) => Math.abs(item.countDiff) > COUNT_DIFF_THRESHOLD) .sort((a, b) => b.countDiff - a.countDiff); + const renderIssues = withCurrent.filter( + (item) => item.current.issues?.initialUpdateCount || item.current.issues?.redundantUpdates?.length + ); added.sort((a, b) => a.name.localeCompare(b.name)); removed.sort((a, b) => a.name.localeCompare(b.name)); @@ -170,13 +175,14 @@ function compareResults(current: MeasureResults, baseline: MeasureResults | null significant, meaningless, countChanged, + renderIssues, added, removed, }; } /** - * Establish statisticial significance of render/execution duration difference build compare entry. + * Establish statistical significance of render/execution duration difference build compare entry. */ function buildCompareEntry(name: string, current: MeasureEntry, baseline: MeasureEntry): CompareEntry { const durationDiff = current.meanDuration - baseline.meanDuration; diff --git a/packages/compare/src/output/console.ts b/packages/compare/src/output/console.ts index 596a23f5..a4ee3387 100644 --- a/packages/compare/src/output/console.ts +++ b/packages/compare/src/output/console.ts @@ -12,18 +12,39 @@ export function printToConsole(data: CompareResult) { logger.log('\n➡️ Significant changes to duration'); data.significant.forEach(printRegularLine); + if (data.significant.length === 0) { + logger.log(' - (none)'); + } logger.log('\n➡️ Meaningless changes to duration'); data.meaningless.forEach(printRegularLine); + if (data.meaningless.length === 0) { + logger.log(' - (none)'); + } - logger.log('\n➡️ Count changes'); + logger.log('\n➡️ Render count changes'); data.countChanged.forEach(printRegularLine); + if (data.countChanged.length === 0) { + logger.log(' - (none)'); + } + + logger.log('\n➡️ Render issues'); + data.renderIssues.forEach(printRenderIssuesLine); + if (data.renderIssues.length === 0) { + logger.log(' - (none)'); + } logger.log('\n➡️ Added scenarios'); data.added.forEach(printAddedLine); + if (data.added.length === 0) { + logger.log(' - (none)'); + } logger.log('\n➡️ Removed scenarios'); data.removed.forEach(printRemovedLine); + if (data.removed.length === 0) { + logger.log(' - (none)'); + } logger.newLine(); } @@ -33,7 +54,28 @@ function printMetadata(name: string, metadata?: MeasureMetadata) { } function printRegularLine(entry: CompareEntry) { - logger.log(` - ${entry.name} [${entry.type}]: ${formatDurationChange(entry)} | ${formatCountChange(entry)}`); + logger.log( + ` - ${entry.name} [${entry.type}]: ${formatDurationChange(entry)} | ${formatCountChange( + entry.current.meanCount, + entry.baseline.meanCount + )}` + ); +} + +function printRenderIssuesLine(entry: CompareEntry | AddedEntry) { + const issues = []; + + const initialUpdateCount = entry.current.issues?.initialUpdateCount; + if (initialUpdateCount) { + issues.push(formatInitialUpdates(initialUpdateCount)); + } + + const redundantUpdates = entry.current.issues?.redundantUpdates; + if (redundantUpdates?.length) { + issues.push(formatRedundantUpdates(redundantUpdates)); + } + + logger.log(` - ${entry.name}: ${issues.join(' | ')}`); } function printAddedLine(entry: AddedEntry) { @@ -49,3 +91,17 @@ function printRemovedLine(entry: RemovedEntry) { ` - ${entry.name} [${entry.type}]: ${formatDuration(baseline.meanDuration)} | ${formatCount(baseline.meanCount)}` ); } + +export function formatInitialUpdates(count: number) { + if (count === 0) return '-'; + if (count === 1) return '1 initial update 🔴'; + + return `${count} initial updates 🔴`; +} + +export function formatRedundantUpdates(redundantUpdates: number[]) { + if (redundantUpdates.length === 0) return '-'; + if (redundantUpdates.length === 1) return `1 redundant update (${redundantUpdates.join(', ')}) 🔴`; + + return `${redundantUpdates.length} redundant updates (${redundantUpdates.join(', ')}) 🔴`; +} diff --git a/packages/compare/src/output/markdown.ts b/packages/compare/src/output/markdown.ts index 328b10e4..f2be3be5 100644 --- a/packages/compare/src/output/markdown.ts +++ b/packages/compare/src/output/markdown.ts @@ -13,6 +13,7 @@ import { } from '../utils/format'; import * as md from '../utils/markdown'; import type { AddedEntry, CompareEntry, CompareResult, RemovedEntry, MeasureEntry, MeasureMetadata } from '../types'; +import { collapsibleSection } from '../utils/markdown'; const tableHeader = ['Name', 'Type', 'Duration', 'Count'] as const; @@ -66,9 +67,18 @@ function buildMarkdown(data: CompareResult) { result += `\n\n${md.heading3('Meaningless Changes To Duration')}`; result += `\n${buildSummaryTable(data.meaningless, true)}`; result += `\n${buildDetailsTable(data.meaningless)}`; - result += `\n\n${md.heading3('Changes To Count')}`; - result += `\n${buildSummaryTable(data.countChanged)}`; - result += `\n${buildDetailsTable(data.countChanged)}`; + + // Skip renders counts if user only has function measurements + const allEntries = [...data.significant, ...data.meaningless, ...data.added, ...data.removed]; + const hasRenderEntries = allEntries.some((e) => e.type === 'render'); + if (hasRenderEntries) { + result += `\n\n${md.heading3('Render Count Changes')}`; + result += `\n${buildSummaryTable(data.countChanged)}`; + result += `\n${buildDetailsTable(data.countChanged)}`; + result += `\n\n${md.heading3('Render Issues')}`; + result += `\n${buildRedundantRendersTable(data.renderIssues)}`; + } + result += `\n\n${md.heading3('Added Scenarios')}`; result += `\n${buildSummaryTable(data.added)}`; result += `\n${buildDetailsTable(data.added)}`; @@ -108,22 +118,23 @@ function buildDetailsTable(entries: Array`); } -export function collapsibleSection(title: string, content: string) { - return `
\n${title}\n\n${content}\n
\n\n`; +function formatRunDurations(values: number[]) { + return values.map((v) => (Number.isInteger(v) ? `${v}` : `${v.toFixed(1)}`)).join(' '); } -export function formatRunDurations(values: number[]) { - return values.map((v) => (Number.isInteger(v) ? `${v}` : `${v.toFixed(1)}`)).join(' '); +function buildRedundantRendersTable(entries: Array) { + if (!entries.length) return md.italic('There are no entries'); + + const tableHeader = ['Name', 'Initial Updates', 'Redundant Updates'] as const; + const rows = entries.map((entry) => [ + entry.name, + formatInitialUpdates(entry.current.issues?.initialUpdateCount), + formatRedundantUpdates(entry.current.issues?.redundantUpdates), + ]); + + return markdownTable([tableHeader, ...rows]); +} + +function formatInitialUpdates(count: number | undefined) { + if (count == null) return '?'; + if (count === 0) return '-'; + + return `${count} 🔴`; +} + +function formatRedundantUpdates(redundantUpdates: number[] | undefined) { + if (redundantUpdates == null) return '?'; + if (redundantUpdates.length === 0) return '-'; + + return `${redundantUpdates.length} (${redundantUpdates.join(', ')}) 🔴`; } diff --git a/packages/compare/src/type-schemas.ts b/packages/compare/src/type-schemas.ts index a52f246e..b0dead0e 100644 --- a/packages/compare/src/type-schemas.ts +++ b/packages/compare/src/type-schemas.ts @@ -43,4 +43,11 @@ export const MeasureEntryScheme = z.object({ /** Array of measured render/execution counts for each run. */ counts: z.array(z.number()), + + issues: z.optional( + z.object({ + initialUpdateCount: z.number().optional(), + redundantUpdates: z.array(z.number()).optional(), + }) + ), }); diff --git a/packages/compare/src/types.ts b/packages/compare/src/types.ts index 8fa9b63e..0d9ab976 100644 --- a/packages/compare/src/types.ts +++ b/packages/compare/src/types.ts @@ -34,6 +34,7 @@ export interface AddedEntry { name: string; type: MeasureType; current: MeasureEntry; + baseline?: undefined; } /** @@ -56,6 +57,7 @@ export interface CompareResult { significant: CompareEntry[]; meaningless: CompareEntry[]; countChanged: CompareEntry[]; + renderIssues: Array; added: AddedEntry[]; removed: RemovedEntry[]; errors: string[]; diff --git a/packages/compare/src/utils/format.ts b/packages/compare/src/utils/format.ts index 77932364..802368cf 100644 --- a/packages/compare/src/utils/format.ts +++ b/packages/compare/src/utils/format.ts @@ -32,15 +32,39 @@ export function formatDurationDiff(value: number): string { return '0 ms'; } -export function formatCount(value: number) { +export function formatCount(value?: number) { + if (value == null) { + return '?'; + } + return Number.isInteger(value) ? `${value}` : `${value.toFixed(2)}`; } -export function formatCountDiff(value: number): string { - if (value > 0) return `+${value}`; - if (value < 0) return `${value}`; + +export function formatCountDiff(current: number, baseline: number): string { + const diff = current - baseline; + if (diff > 0) return `+${diff}`; + if (diff < 0) return `${diff}`; return '±0'; } +export function formatCountChange(current?: number, baseline?: number): string { + let output = `${formatCount(baseline)} → ${formatCount(current)}`; + + if (baseline != null && current != null && baseline !== current) { + const parts = [formatCountDiff(current, baseline)]; + + if (baseline > 0) { + const relativeDiff = (current - baseline) / baseline; + parts.push(formatPercentChange(relativeDiff)); + } + + output += ` (${parts.join(', ')})`; + } + + output += ` ${getCountChangeSymbols(current, baseline)}`; + return output; +} + export function formatChange(value: number): string { if (value > 0) return `+${value}`; if (value < 0) return `${value}`; @@ -76,25 +100,16 @@ function getDurationChangeSymbols(entry: CompareEntry) { return ''; } -export function formatCountChange(entry: CompareEntry) { - const { baseline, current } = entry; - - let output = `${formatCount(baseline.meanCount)} → ${formatCount(current.meanCount)}`; - - if (baseline.meanCount != current.meanCount) { - output += ` (${formatCountDiff(entry.countDiff)}, ${formatPercentChange(entry.relativeCountDiff)})`; +function getCountChangeSymbols(current?: number, baseline?: number) { + if (current == null || baseline == null) { + return ''; } - output += ` ${getCountChangeSymbols(entry)}`; - - return output; -} - -function getCountChangeSymbols(entry: CompareEntry) { - if (entry.countDiff > 1.5) return '🔴🔴'; - if (entry.countDiff > 0.5) return '🔴'; - if (entry.countDiff < -1.5) return '🟢🟢'; - if (entry.countDiff < -0.5) return '🟢'; + const diff = current - baseline; + if (diff > 1.5) return '🔴🔴'; + if (diff > 0.5) return '🔴'; + if (diff < -1.5) return '🟢🟢'; + if (diff < -0.5) return '🟢'; return ''; } diff --git a/packages/compare/src/utils/markdown.ts b/packages/compare/src/utils/markdown.ts index 0f04cd49..cdd9200e 100644 --- a/packages/compare/src/utils/markdown.ts +++ b/packages/compare/src/utils/markdown.ts @@ -17,3 +17,7 @@ export function bold(text: string) { export function italic(text: string) { return `*${text}*`; } + +export function collapsibleSection(title: string, content: string) { + return `
\n${title}\n\n${content}\n
\n\n`; +} diff --git a/packages/measure/package.json b/packages/measure/package.json index d2ceee21..f59f1b6c 100644 --- a/packages/measure/package.json +++ b/packages/measure/package.json @@ -36,7 +36,8 @@ "homepage": "https://github.com/callstack/reassure#readme", "dependencies": { "@callstack/reassure-logger": "1.0.0-rc.4", - "mathjs": "^12.4.2" + "mathjs": "^12.4.2", + "pretty-format": "^29.7.0" }, "devDependencies": { "@babel/core": "^7.24.5", diff --git a/packages/measure/src/__tests__/measure-function.test.tsx b/packages/measure/src/__tests__/measure-function.test.tsx index 25e001e9..3fc3dcf3 100644 --- a/packages/measure/src/__tests__/measure-function.test.tsx +++ b/packages/measure/src/__tests__/measure-function.test.tsx @@ -1,6 +1,6 @@ import stripAnsi from 'strip-ansi'; import { measureFunction } from '../measure-function'; -import { resetHasShownFlagsOutput } from '../output'; +import { setHasShownFlagsOutput } from '../output'; // Exponentially slow function function fib(n: number): number { @@ -56,7 +56,7 @@ beforeEach(() => { }); test('measureFunction should log error when running under incorrect node flags', async () => { - resetHasShownFlagsOutput(); + setHasShownFlagsOutput(false); const results = await measureFunction(jest.fn(), { runs: 1, writeFile: false }); expect(results.runs).toBe(1); diff --git a/packages/measure/src/__tests__/measure-renders.test.tsx b/packages/measure/src/__tests__/measure-renders.test.tsx index a27a33a3..e50663a5 100644 --- a/packages/measure/src/__tests__/measure-renders.test.tsx +++ b/packages/measure/src/__tests__/measure-renders.test.tsx @@ -1,18 +1,16 @@ import * as React from 'react'; -import { View } from 'react-native'; +import { View, Text, Pressable } from 'react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; import stripAnsi from 'strip-ansi'; import { buildUiToRender, measureRenders } from '../measure-renders'; -import { resetHasShownFlagsOutput } from '../output'; +import { setHasShownFlagsOutput } from '../output'; const errorsToIgnore = ['❌ Measure code is running under incorrect Node.js configuration.']; const realConsole = jest.requireActual('console') as Console; beforeEach(() => { - jest.spyOn(realConsole, 'error').mockImplementation((message) => { - if (!errorsToIgnore.some((error) => message.includes(error))) { - realConsole.error(message); - } - }); + setHasShownFlagsOutput(true); + jest.mocked(realConsole.error).mockRestore?.(); }); test('measureRenders run test given number of times', async () => { @@ -41,7 +39,13 @@ test('measureRenders applies "warmupRuns" option', async () => { }); test('measureRenders should log error when running under incorrect node flags', async () => { - resetHasShownFlagsOutput(); + jest.spyOn(realConsole, 'error').mockImplementation((message) => { + if (!errorsToIgnore.some((error) => message.includes(error))) { + realConsole.error(message); + } + }); + + setHasShownFlagsOutput(false); const results = await measureRenders(, { runs: 1, writeFile: false }); expect(results.runs).toBe(1); @@ -68,6 +72,144 @@ test('measureRenders does not measure wrapper execution', async () => { expect(results.stdevCount).toBe(0); }); +const Regular = () => { + const [count, setCount] = React.useState(0); + + return ( + + setCount((c) => c + 1)}> + Increment + + + Count: ${count} + + ); +}; + +test('measureRenders correctly measures regular renders', async () => { + const scenario = async () => { + await fireEvent.press(screen.getByText('Increment')); + }; + + const results = await measureRenders(, { scenario, writeFile: false }); + expect(results.issues.initialUpdateCount).toBe(0); + expect(results.issues.redundantUpdates).toEqual([]); +}); + +const InitialUpdates = ({ updateCount }: { updateCount: number }) => { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + if (count < updateCount) { + setCount((c) => c + 1); + } + }); + + return ( + + Count: ${count} + + ); +}; + +test('measureRenders detects redundant initial renders', async () => { + const results = await measureRenders(, { writeFile: false }); + expect(results.issues.initialUpdateCount).toBe(1); + expect(results.issues.redundantUpdates).toEqual([]); +}); + +test('measureRenders detects multiple redundant initial renders', async () => { + const results = await measureRenders(, { writeFile: false }); + expect(results.issues.initialUpdateCount).toBe(5); + expect(results.issues.redundantUpdates).toEqual([]); +}); + +const RenderIssues = () => { + const [count, setCount] = React.useState(0); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, forceRender] = React.useState(0); + + return ( + + forceRender((c) => c + 1)}> + Redundant + + setCount((c) => c + 1)}> + Valid + + + Visible: {count} + + ); +}; + +test('measureRenders detects redundant updates', async () => { + const scenario = async () => { + await fireEvent.press(screen.getByText('Redundant')); + }; + + const results = await measureRenders(, { scenario, writeFile: false }); + expect(results.issues.redundantUpdates).toEqual([1]); + expect(results.issues.initialUpdateCount).toBe(0); +}); + +test('measureRenders detects multiple redundant updates', async () => { + const scenario = async () => { + await fireEvent.press(screen.getByText('Redundant')); + await fireEvent.press(screen.getByText('Valid')); + await fireEvent.press(screen.getByText('Redundant')); + }; + + const results = await measureRenders(, { scenario, writeFile: false }); + expect(results.issues.redundantUpdates).toEqual([1, 3]); + expect(results.issues.initialUpdateCount).toBe(0); +}); + +const AsyncMacroTaskEffect = () => { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + setTimeout(() => setCount(1), 0); + }, []); + + return ( + + Count: ${count} + + ); +}; + +test('ignores async macro-tasks effect', async () => { + const results = await measureRenders(, { writeFile: false }); + expect(results.issues.initialUpdateCount).toBe(0); + expect(results.issues.redundantUpdates).toEqual([]); +}); + +const AsyncMicrotaskEffect = () => { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + const asyncSet = async () => { + await Promise.resolve(); + setCount(1); + }; + + void asyncSet(); + }, []); + + return ( + + Count: ${count} + + ); +}; + +test('ignores async micro-tasks effect', async () => { + const results = await measureRenders(, { writeFile: false }); + expect(results.issues.initialUpdateCount).toBe(0); + expect(results.issues.redundantUpdates).toEqual([]); +}); + function Wrapper({ children }: React.PropsWithChildren<{}>) { return {children}; } diff --git a/packages/measure/src/measure-renders.tsx b/packages/measure/src/measure-renders.tsx index 81faa728..9a3a0ad3 100644 --- a/packages/measure/src/measure-renders.tsx +++ b/packages/measure/src/measure-renders.tsx @@ -3,8 +3,9 @@ import * as logger from '@callstack/reassure-logger'; import { config } from './config'; import { RunResult, processRunResults } from './measure-helpers'; import { showFlagsOutputIfNeeded, writeTestStats } from './output'; -import { resolveTestingLibrary } from './testing-library'; -import type { MeasureResults } from './types'; +import { resolveTestingLibrary, getTestingLibrary } from './testing-library'; +import type { MeasureRendersResults } from './types'; +import { ElementJsonTree, detectRedundantUpdates } from './redundant-renders'; logger.configure({ verbose: process.env.REASSURE_VERBOSE === 'true' || process.env.REASSURE_VERBOSE === '1', @@ -19,7 +20,10 @@ export interface MeasureRendersOptions { writeFile?: boolean; } -export async function measureRenders(ui: React.ReactElement, options?: MeasureRendersOptions): Promise { +export async function measureRenders( + ui: React.ReactElement, + options?: MeasureRendersOptions +): Promise { const stats = await measureRendersInternal(ui, options); if (options?.writeFile !== false) { @@ -35,7 +39,7 @@ export async function measureRenders(ui: React.ReactElement, options?: MeasureRe export async function measurePerformance( ui: React.ReactElement, options?: MeasureRendersOptions -): Promise { +): Promise { logger.warnOnce( 'The `measurePerformance` function has been renamed to `measureRenders`.\n\nThe `measurePerformance` alias is now deprecated and will be removed in future releases.' ); @@ -46,22 +50,46 @@ export async function measurePerformance( async function measureRendersInternal( ui: React.ReactElement, options?: MeasureRendersOptions -): Promise { +): Promise { const runs = options?.runs ?? config.runs; const scenario = options?.scenario; const warmupRuns = options?.warmupRuns ?? config.warmupRuns; const { render, cleanup } = resolveTestingLibrary(); + const testingLibrary = getTestingLibrary(); showFlagsOutputIfNeeded(); const runResults: RunResult[] = []; let hasTooLateRender = false; + + const renderJsonTrees: ElementJsonTree[] = []; + let initialRenderCount = 0; + for (let i = 0; i < runs + warmupRuns; i += 1) { let duration = 0; let count = 0; let isFinished = false; + let renderResult: any = null; + + const captureRenderDetails = () => { + // We capture render details only on the first run + if (i !== 0) { + return; + } + + // Initial render did not finish yet, so there is no "render" result yet and we cannot analyze the element tree. + if (renderResult == null) { + initialRenderCount += 1; + return; + } + + if (testingLibrary === 'react-native') { + renderJsonTrees.push(renderResult.toJSON()); + } + }; + const handleRender = (_id: string, _phase: string, actualDuration: number) => { duration += actualDuration; count += 1; @@ -69,13 +97,16 @@ async function measureRendersInternal( if (isFinished) { hasTooLateRender = true; } + + captureRenderDetails(); }; const uiToRender = buildUiToRender(ui, handleRender, options?.wrapper); - const screen = render(uiToRender); + renderResult = render(uiToRender); + captureRenderDetails(); if (scenario) { - await scenario(screen); + await scenario(renderResult); } cleanup(); @@ -93,7 +124,13 @@ async function measureRendersInternal( ); } - return processRunResults(runResults, warmupRuns); + return { + ...processRunResults(runResults, warmupRuns), + issues: { + initialUpdateCount: initialRenderCount - 1, + redundantUpdates: detectRedundantUpdates(renderJsonTrees, initialRenderCount), + }, + }; } export function buildUiToRender( diff --git a/packages/measure/src/output.ts b/packages/measure/src/output.ts index 94de6aec..40a00ac2 100644 --- a/packages/measure/src/output.ts +++ b/packages/measure/src/output.ts @@ -47,6 +47,6 @@ export function showFlagsOutputIfNeeded() { hasShownFlagsOutput = true; } -export function resetHasShownFlagsOutput() { - hasShownFlagsOutput = false; +export function setHasShownFlagsOutput(value: boolean) { + hasShownFlagsOutput = value; } diff --git a/packages/measure/src/redundant-renders.tsx b/packages/measure/src/redundant-renders.tsx new file mode 100644 index 00000000..2e922aea --- /dev/null +++ b/packages/measure/src/redundant-renders.tsx @@ -0,0 +1,30 @@ +import type { ReactTestRendererJSON } from 'react-test-renderer'; +import { format as prettyFormat, plugins } from 'pretty-format'; + +export type ElementJsonTree = ReactTestRendererJSON | ReactTestRendererJSON[] | null; + +export function detectRedundantUpdates(elementTrees: ElementJsonTree[], initialRenderCount: number): number[] { + const result = []; + + for (let i = 1; i < elementTrees.length; i += 1) { + if (isJsonTreeEqual(elementTrees[i], elementTrees[i - 1])) { + // We want to return correct render index, so we need to take into account: + // - initial render count that happened before we have access to the element tree + // - the fact that the last initial render is double counted as first element tree + result.push(i + initialRenderCount - 1); + } + } + + return result; +} + +const formatOptionsZeroIndent = { + plugins: [plugins.ReactTestComponent], + indent: 0, +}; + +function isJsonTreeEqual(left: ElementJsonTree | null, right: ElementJsonTree | null): boolean { + const formattedLeft = prettyFormat(left, formatOptionsZeroIndent); + const formattedRight = prettyFormat(right, formatOptionsZeroIndent); + return formattedLeft === formattedRight; +} diff --git a/packages/measure/src/testing-library.ts b/packages/measure/src/testing-library.ts index c9f0ce55..dc768a72 100644 --- a/packages/measure/src/testing-library.ts +++ b/packages/measure/src/testing-library.ts @@ -81,3 +81,19 @@ export function resolveTestingLibrary(): TestingLibraryApi { `\nAdd either of these testing libraries to your 'package.json'` ); } + +export function getTestingLibrary(): string | null { + if (typeof config.testingLibrary === 'string') { + config.testingLibrary; + } + + if (RNTL != null) { + return 'react-native'; + } + + if (RTL != null) { + return 'react'; + } + + return null; +} diff --git a/packages/measure/src/types.ts b/packages/measure/src/types.ts index 97d122af..a01e6e8e 100644 --- a/packages/measure/src/types.ts +++ b/packages/measure/src/types.ts @@ -29,3 +29,23 @@ export interface MeasureResults { /** Array of measured render/execution count for each run */ counts: number[]; } + +export interface MeasureRendersResults extends MeasureResults { + issues: RenderIssues; +} + +export interface RenderIssues { + /** + * Update renders (re-renders) that happened immediately after component was created + * e.g., synchronous `useEffects` containing `setState`. + * + * This types of re-renders can be optimized by initializing the component with proper state in + * the initial render. + */ + initialUpdateCount: number; + + /** + * Re-renders that resulted in rendering the same output as the previous render. This arrays contains numbers of render + */ + redundantUpdates: number[]; +} diff --git a/test-apps/native/src/RedundantRenders.perf-test.tsx b/test-apps/native/src/RedundantRenders.perf-test.tsx new file mode 100644 index 00000000..c873e817 --- /dev/null +++ b/test-apps/native/src/RedundantRenders.perf-test.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { measureRenders } from 'reassure'; + +jest.setTimeout(60_000); + +type RenderIssuesProps = { + initial?: number; + redundant?: number; +}; + +const RenderIssues = ({ initial: initialUpdates = 0 }: RenderIssuesProps) => { + const [count, setCount] = React.useState(0); + const [_, forceRender] = React.useState(0); + + React.useEffect(() => { + if (count < initialUpdates) { + setCount(c => c + 1); + } + }, [count, initialUpdates]); + + return ( + + Count: ${count} + + forceRender(c => c + 1)}> + Inc + + + ); +}; + +test('InitialRenders 1', async () => { + await measureRenders(); +}); + +test('InitialRenders 3', async () => { + await measureRenders(); +}); + +test('RedundantUpdates', async () => { + const scenario = async () => { + await fireEvent.press(screen.getByText('Inc')); + }; + + await measureRenders(, { scenario }); +}); + +test('ManyRenderIssues', async () => { + const scenario = async () => { + await fireEvent.press(screen.getByText('Inc')); + await fireEvent.press(screen.getByText('Inc')); + }; + + await measureRenders(, { scenario }); +}); diff --git a/yarn.lock b/yarn.lock index 1145f5a4..7ad9e73e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2384,6 +2384,7 @@ __metadata: jest: "npm:^29.7.0" mathjs: "npm:^12.4.2" prettier: "npm:^2.8.8" + pretty-format: "npm:^29.7.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" react-native: "npm:0.74.1"