Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/measure/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"@babel/runtime": "^7.26.9",
"@relmify/jest-serializer-strip-ansi": "^1.0.2",
"@testing-library/react": "^16.2.0",
"@testing-library/react-native": "^13.2.0",
"@testing-library/react-native": "^13.3.0",
"@types/jest": "^30.0.0",
"@types/react": "^19.0.0",
"babel-jest": "^30.0.2",
Expand All @@ -63,8 +63,14 @@
"typescript": "^5.8.2"
},
"peerDependencies": {
"@react-native/testing-library": "^13.3.0",
"react": ">=18.0.0"
},
"peerDependenciesMeta": {
"@react-native/testing-library": {
"optional": true
}
},
"react-native-builder-bob": {
"source": "src",
"output": "lib",
Expand Down
7 changes: 4 additions & 3 deletions packages/measure/src/__tests__/measure-renders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as React from 'react';
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 { measureRenders } from '../measure-renders';
import { buildUiToRender } from '../measure-renders-common';
import { setHasShownFlagsOutput } from '../output';

const errorsToIgnore = ['❌ Measure code is running under incorrect Node.js configuration.'];
Expand Down Expand Up @@ -225,9 +226,9 @@ const AsyncMicrotaskEffect = () => {
);
};

test('ignores async micro-tasks effect', async () => {
test('does not ignore async micro-tasks effect', async () => {
const results = await measureRenders(<AsyncMicrotaskEffect />, { writeFile: false });
expect(results.issues.initialUpdateCount).toBe(0);
expect(results.issues.initialUpdateCount).toBe(1);
expect(results.issues.redundantUpdates).toEqual([]);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/measure/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export { configure, resetToDefaults } from './config';
export { measureRenders, measurePerformance } from './measure-renders';
export { measureFunction } from './measure-function';
export { measureAsyncFunction } from './measure-async-function';
export type { MeasureRendersOptions } from './measure-renders';
export type { MeasureRendersOptions } from './measure-renders-common';
export type { MeasureFunctionOptions } from './measure-function';
export type { MeasureAsyncFunctionOptions } from './measure-async-function';
export type { MeasureType, MeasureResults } from './types';
116 changes: 116 additions & 0 deletions packages/measure/src/measure-renders-common.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as React from 'react';
import * as logger from '@callstack/reassure-logger';
import { config } from './config';
import { RunResult, processRunResults } from './measure-helpers';
import { showFlagsOutputIfNeeded } from './output';
import { applyRenderPolyfills, revertRenderPolyfills } from './polyfills';
import { ElementJsonTree, detectRedundantUpdates } from './redundant-renders';
import { resolveTestingLibrary, getTestingLibrary } from './testing-library';
import type { MeasureRendersResults } from './types';

logger.configure({
verbose: process.env.REASSURE_VERBOSE === 'true' || process.env.REASSURE_VERBOSE === '1',
silent: process.env.REASSURE_SILENT === 'true' || process.env.REASSURE_SILENT === '1',
});

export interface MeasureRendersOptions {
runs?: number;
warmupRuns?: number;
removeOutliers?: boolean;
wrapper?: React.ComponentType<{ children: React.ReactElement }>;
scenario?: (screen: any) => Promise<any>;
writeFile?: boolean;
beforeEach?: () => Promise<void> | void;
afterEach?: () => Promise<void> | void;
}

export async function measureRendersInternal(
ui: React.ReactElement,
options?: MeasureRendersOptions
): Promise<MeasureRendersResults> {
const runs = options?.runs ?? config.runs;
const scenario = options?.scenario;
const warmupRuns = options?.warmupRuns ?? config.warmupRuns;
const removeOutliers = options?.removeOutliers ?? config.removeOutliers;

const { render, cleanup } = resolveTestingLibrary();
const testingLibrary = getTestingLibrary();

showFlagsOutputIfNeeded();
applyRenderPolyfills();

const runResults: RunResult[] = [];
const renderJsonTrees: ElementJsonTree[] = [];
let initialRenderCount = 0;

for (let iteration = 0; iteration < runs + warmupRuns; iteration += 1) {
await options?.beforeEach?.();

let duration = 0;
let count = 0;
let renderResult: any = null;

const captureRenderDetails = () => {
// We capture render details only on the first run
if (iteration !== 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;

captureRenderDetails();
};

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

if (scenario) {
await scenario(renderResult);
}

cleanup();
global.gc?.();

await options?.afterEach?.();

runResults.push({ duration, count });
}

revertRenderPolyfills();

return {
...processRunResults(runResults, { warmupRuns, removeOutliers }),
issues: {
initialUpdateCount: initialRenderCount - 1,
redundantUpdates: detectRedundantUpdates(renderJsonTrees, initialRenderCount),
},
};
}

export function buildUiToRender(
ui: React.ReactElement,
onRender: React.ProfilerOnRenderCallback,
Wrapper?: React.ComponentType<{ children: React.ReactElement }>
) {
const uiWithProfiler = (
<React.Profiler id="REASSURE_ROOT" onRender={onRender}>
{ui}
</React.Profiler>
);

return Wrapper ? <Wrapper>{uiWithProfiler}</Wrapper> : uiWithProfiler;
}
103 changes: 103 additions & 0 deletions packages/measure/src/measure-renders-native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as React from 'react';
import * as logger from '@callstack/reassure-logger';
// eslint-disable-next-line import/no-extraneous-dependencies
import { renderAsync, screen, cleanup } from '@testing-library/react-native';
import { config } from './config';
import { RunResult, processRunResults } from './measure-helpers';
import { showFlagsOutputIfNeeded } from './output';
import { applyRenderPolyfills, revertRenderPolyfills } from './polyfills';
import { ElementJsonTree, detectRedundantUpdates } from './redundant-renders';
import type { MeasureRendersResults } from './types';
import { MeasureRendersOptions } from './measure-renders-common';

logger.configure({
verbose: process.env.REASSURE_VERBOSE === 'true' || process.env.REASSURE_VERBOSE === '1',
silent: process.env.REASSURE_SILENT === 'true' || process.env.REASSURE_SILENT === '1',
});

export async function measureRendersNative(
ui: React.ReactElement,
options?: MeasureRendersOptions
): Promise<MeasureRendersResults> {
const runs = options?.runs ?? config.runs;
const scenario = options?.scenario;
const warmupRuns = options?.warmupRuns ?? config.warmupRuns;
const removeOutliers = options?.removeOutliers ?? config.removeOutliers;

showFlagsOutputIfNeeded();
applyRenderPolyfills();

const runResults: RunResult[] = [];
const renderJsonTrees: ElementJsonTree[] = [];
let initialRenderCount = 0;

for (let iteration = 0; iteration < runs + warmupRuns; iteration += 1) {
await options?.beforeEach?.();

let duration = 0;
let count = 0;
let renderResult: any = null;

const captureRenderDetails = () => {
// We capture render details only on the first run
if (iteration !== 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;
}

renderJsonTrees.push(renderResult.toJSON());
};

const handleRender = (_id: string, _phase: string, actualDuration: number) => {
duration += actualDuration;
count += 1;

captureRenderDetails();
};

const uiToRender = buildUiToRender(ui, handleRender, options?.wrapper);
renderResult = await renderAsync(uiToRender);
captureRenderDetails();

if (scenario) {
await scenario(renderResult);
}

await screen.unmountAsync();
cleanup();
global.gc?.();

await options?.afterEach?.();

runResults.push({ duration, count });
}

revertRenderPolyfills();

return {
...processRunResults(runResults, { warmupRuns, removeOutliers }),
issues: {
initialUpdateCount: initialRenderCount - 1,
redundantUpdates: detectRedundantUpdates(renderJsonTrees, initialRenderCount),
},
};
}

export function buildUiToRender(
ui: React.ReactElement,
onRender: React.ProfilerOnRenderCallback,
Wrapper?: React.ComponentType<{ children: React.ReactElement }>
) {
const uiWithProfiler = (
<React.Profiler id="REASSURE_ROOT" onRender={onRender}>
{ui}
</React.Profiler>
);

return Wrapper ? <Wrapper>{uiWithProfiler}</Wrapper> : uiWithProfiler;
}
Loading
Loading