diff --git a/packages/measure/package.json b/packages/measure/package.json index 7f8d33ec..39dd69ea 100644 --- a/packages/measure/package.json +++ b/packages/measure/package.json @@ -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", @@ -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", diff --git a/packages/measure/src/__tests__/measure-renders.test.tsx b/packages/measure/src/__tests__/measure-renders.test.tsx index 1a75bef4..be6de0f9 100644 --- a/packages/measure/src/__tests__/measure-renders.test.tsx +++ b/packages/measure/src/__tests__/measure-renders.test.tsx @@ -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.']; @@ -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(, { writeFile: false }); - expect(results.issues.initialUpdateCount).toBe(0); + expect(results.issues.initialUpdateCount).toBe(1); expect(results.issues.redundantUpdates).toEqual([]); }); diff --git a/packages/measure/src/index.ts b/packages/measure/src/index.ts index c0a1899f..99e67dba 100644 --- a/packages/measure/src/index.ts +++ b/packages/measure/src/index.ts @@ -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'; diff --git a/packages/measure/src/measure-renders-common.tsx b/packages/measure/src/measure-renders-common.tsx new file mode 100644 index 00000000..b1a21edb --- /dev/null +++ b/packages/measure/src/measure-renders-common.tsx @@ -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; + writeFile?: boolean; + beforeEach?: () => Promise | void; + afterEach?: () => Promise | void; +} + +export async function measureRendersInternal( + ui: React.ReactElement, + options?: MeasureRendersOptions +): Promise { + 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 = ( + + {ui} + + ); + + return Wrapper ? {uiWithProfiler} : uiWithProfiler; +} diff --git a/packages/measure/src/measure-renders-native.tsx b/packages/measure/src/measure-renders-native.tsx new file mode 100644 index 00000000..d5ada45e --- /dev/null +++ b/packages/measure/src/measure-renders-native.tsx @@ -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 { + 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 = ( + + {ui} + + ); + + return Wrapper ? {uiWithProfiler} : uiWithProfiler; +} diff --git a/packages/measure/src/measure-renders.tsx b/packages/measure/src/measure-renders.tsx index 6e746d18..68df741b 100644 --- a/packages/measure/src/measure-renders.tsx +++ b/packages/measure/src/measure-renders.tsx @@ -1,34 +1,20 @@ -import * as React from 'react'; import * as logger from '@callstack/reassure-logger'; -import { config } from './config'; -import { RunResult, processRunResults } from './measure-helpers'; -import { showFlagsOutputIfNeeded, writeTestStats } 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; - writeFile?: boolean; - beforeEach?: () => Promise | void; - afterEach?: () => Promise | void; -} +import { measureRendersInternal, MeasureRendersOptions } from './measure-renders-common'; +import { measureRendersNative } from './measure-renders-native'; +import { writeTestStats } from './output'; +import { getTestingLibrary } from './testing-library'; +import { MeasureRendersResults } from './types'; export async function measureRenders( ui: React.ReactElement, options?: MeasureRendersOptions ): Promise { - const stats = await measureRendersInternal(ui, options); + let stats: MeasureRendersResults; + if (getTestingLibrary() === 'react-native') { + stats = await measureRendersNative(ui, options); + } else { + stats = await measureRendersInternal(ui, options); + } if (options?.writeFile !== false) { await writeTestStats(stats, 'render'); @@ -50,94 +36,3 @@ export async function measurePerformance( return await measureRenders(ui, options); } - -async function measureRendersInternal( - ui: React.ReactElement, - options?: MeasureRendersOptions -): Promise { - 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 = ( - - {ui} - - ); - - return Wrapper ? {uiWithProfiler} : uiWithProfiler; -} diff --git a/test-apps/native/package.json b/test-apps/native/package.json index 2ee45c02..5b911a9c 100644 --- a/test-apps/native/package.json +++ b/test-apps/native/package.json @@ -24,7 +24,7 @@ "@react-native/eslint-config": "0.78.0", "@react-native/metro-config": "0.78.0", "@react-native/typescript-config": "0.78.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", "@types/react-test-renderer": "^19.0.0", diff --git a/yarn.lock b/yarn.lock index 2aa8ad67..7a23b7a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1643,7 +1643,7 @@ __metadata: "@callstack/reassure-logger": "npm:1.4.0" "@relmify/jest-serializer-strip-ansi": "npm:^1.0.2" "@testing-library/react": "npm:^16.2.0" - "@testing-library/react-native": "npm:^13.2.0" + "@testing-library/react-native": "npm:^13.3.0" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.0.0" babel-jest: "npm:^30.0.2" @@ -1659,7 +1659,11 @@ __metadata: strip-ansi: "npm:^6.0.1" typescript: "npm:^5.8.2" peerDependencies: + "@react-native/testing-library": ^13.3.0 react: ">=18.0.0" + peerDependenciesMeta: + "@react-native/testing-library": + optional: true languageName: unknown linkType: soft @@ -2317,6 +2321,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/schemas@npm:30.0.5" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10c0/449dcd7ec5c6505e9ac3169d1143937e67044ae3e66a729ce4baf31812dfd30535f2b3b2934393c97cfdf5984ff581120e6b38f62b8560c8b5b7cc07f4175f65 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -3396,13 +3409,13 @@ __metadata: languageName: node linkType: hard -"@testing-library/react-native@npm:^13.2.0": - version: 13.2.0 - resolution: "@testing-library/react-native@npm:13.2.0" +"@testing-library/react-native@npm:^13.3.0": + version: 13.3.2 + resolution: "@testing-library/react-native@npm:13.3.2" dependencies: - chalk: "npm:^4.1.2" - jest-matcher-utils: "npm:^29.7.0" - pretty-format: "npm:^29.7.0" + jest-matcher-utils: "npm:^30.0.5" + picocolors: "npm:^1.1.1" + pretty-format: "npm:^30.0.5" redent: "npm:^3.0.0" peerDependencies: jest: ">=29.0.0" @@ -3412,7 +3425,7 @@ __metadata: peerDependenciesMeta: jest: optional: true - checksum: 10c0/5ed8e09f82f45c057f12a716008f31abf934e6a3d84955540e2ab96d7534c82b9afdb0af050e986d8b63ae9dd8272f8a752c45ecb847a11e7549f30de3d84427 + checksum: 10c0/c76baefa200183aa79e45d672370061a7ff8bdb8b45c02fd83c903604c265bae2d4ca194ddb71c90646cc2649eaf0d6e6ffc1ea375823d96e81635423b51e024 languageName: node linkType: hard @@ -5698,13 +5711,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^29.6.3": - version: 29.6.3 - resolution: "diff-sequences@npm:29.6.3" - checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 - languageName: node - linkType: hard - "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -8270,15 +8276,15 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-diff@npm:29.7.0" +"jest-diff@npm:30.0.5": + version: 30.0.5 + resolution: "jest-diff@npm:30.0.5" dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^29.6.3" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 + "@jest/diff-sequences": "npm:30.0.1" + "@jest/get-type": "npm:30.0.1" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.0.5" + checksum: 10c0/b218ced37b7676f578ea866762f04caa74901bdcf3f593872aa9a4991a586302651a1d16bb0386772adacc7580a452ec621359af75d733c0b50ea947fe1881d3 languageName: node linkType: hard @@ -8407,15 +8413,15 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-matcher-utils@npm:29.7.0" +"jest-matcher-utils@npm:^30.0.5": + version: 30.0.5 + resolution: "jest-matcher-utils@npm:30.0.5" dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/0d0e70b28fa5c7d4dce701dc1f46ae0922102aadc24ed45d594dd9b7ae0a8a6ef8b216718d1ab79e451291217e05d4d49a82666e1a3cc2b428b75cd9c933244e + "@jest/get-type": "npm:30.0.1" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.0.5" + pretty-format: "npm:30.0.5" + checksum: 10c0/231d891b29bfc218f2f5739c10873b6671426e31ad1c5538eed1531e62608fd3f60d32f41821332a6cf41f1614fd37361434c754fdd49c849b35ef2e5156c02e languageName: node linkType: hard @@ -10591,6 +10597,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.0.5, pretty-format@npm:^30.0.5": + version: 30.0.5 + resolution: "pretty-format@npm:30.0.5" + dependencies: + "@jest/schemas": "npm:30.0.5" + ansi-styles: "npm:^5.2.0" + react-is: "npm:^18.3.1" + checksum: 10c0/9f6cf1af5c3169093866c80adbfdad32f69c692b62f24ba3ca8cdec8519336123323f896396f9fa40346a41b197c5f6be15aec4d8620819f12496afaaca93f81 + languageName: node + linkType: hard + "pretty-format@npm:^26.6.2": version: 26.6.2 resolution: "pretty-format@npm:26.6.2" @@ -10970,7 +10987,7 @@ __metadata: "@react-native/eslint-config": "npm:0.78.0" "@react-native/metro-config": "npm:0.78.0" "@react-native/typescript-config": "npm:0.78.0" - "@testing-library/react-native": "npm:^13.2.0" + "@testing-library/react-native": "npm:^13.3.0" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.0.0" "@types/react-test-renderer": "npm:^19.0.0"