From 59586b3a2ccc1631eb8bca76247a92548baed8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 10:55:25 +0200 Subject: [PATCH 01/16] feat: flag for enabling concurrent rendering --- jest-setup.ts | 3 ++- src/__tests__/config.test.ts | 5 +++++ src/config.ts | 8 ++++++++ src/render.tsx | 27 +++++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/jest-setup.ts b/jest-setup.ts index a4d893a18..e9d88f7ce 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,6 +1,7 @@ -import { resetToDefaults } from './src/pure'; +import { resetToDefaults, configure } from './src/pure'; import './src/matchers/extend-expect'; beforeEach(() => { resetToDefaults(); + configure({ legacyRoot: false }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index aca62f304..2e15791d0 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,5 +1,9 @@ import { getConfig, configure, resetToDefaults, configureInternal } from '../config'; +beforeEach(() => { + resetToDefaults(); +}); + test('getConfig() returns existing configuration', () => { expect(getConfig().asyncUtilTimeout).toEqual(1000); expect(getConfig().defaultIncludeHiddenElements).toEqual(false); @@ -12,6 +16,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, + legacyRoot: true, }); }); diff --git a/src/config.ts b/src/config.ts index c343a3e15..479f14255 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,13 @@ export type Config = { /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; + + /** + * Only works if used with React 18. + * Set to `true` if you want to force synchronous rendering. + * Otherwise `render` will default to concurrent React if available. + */ + legacyRoot: boolean; }; export type ConfigAliasOptions = { @@ -37,6 +44,7 @@ export type InternalConfig = Config & { const defaultConfig: InternalConfig = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, + legacyRoot: true, }; let config = { ...defaultConfig }; diff --git a/src/render.tsx b/src/render.tsx index 5f31dcb2a..0de68c218 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -1,4 +1,8 @@ -import type { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer'; +import type { + ReactTestInstance, + ReactTestRenderer, + TestRendererOptions, +} from 'react-test-renderer'; import * as React from 'react'; import { Profiler } from 'react'; import act from './act'; @@ -14,7 +18,19 @@ import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; export interface RenderOptions { + /** + * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating + * reusable custom render functions for common data providers. + */ wrapper?: React.ComponentType; + + /** + * Only works if used with React 18. + * Set to `true` if you want to force synchronous rendering. + * Otherwise `render` will default to concurrent React if available. + */ + legacyRoot?: boolean | undefined; + createNodeMock?: (element: React.ReactElement) => unknown; unstable_validateStringsRenderedWithinText?: boolean; } @@ -39,11 +55,18 @@ export function renderInternal( ) { const { wrapper: Wrapper, + legacyRoot, detectHostComponentNames = true, unstable_validateStringsRenderedWithinText, - ...testRendererOptions + ...rest } = options || {}; + const testRendererOptions: TestRendererOptions = { + // @ts-expect-error incomplete typing on RTR package + unstable_isConcurrent: !(legacyRoot ?? getConfig().legacyRoot), + ...rest, + }; + if (detectHostComponentNames) { configureHostComponentNamesIfNeeded(); } From 83d3eae561574adabfa51decaa754c763643c3aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 11:32:14 +0200 Subject: [PATCH 02/16] refactor: rename option --- jest-setup.ts | 2 +- src/__tests__/config.test.ts | 2 +- src/__tests__/render.test.tsx | 10 ++++++++++ src/config.ts | 9 ++++----- src/render.tsx | 11 +++++------ 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/jest-setup.ts b/jest-setup.ts index e9d88f7ce..d407a394f 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -3,5 +3,5 @@ import './src/matchers/extend-expect'; beforeEach(() => { resetToDefaults(); - configure({ legacyRoot: false }); + configure({ concurrentRendering: false }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 2e15791d0..2ca58416f 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -16,7 +16,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - legacyRoot: true, + concurrentRendering: false, }); }); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 0c09b3904..ee84d1e0f 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -241,3 +241,13 @@ test('render calls detects host component names', () => { render(); expect(getConfig().hostComponentNames).not.toBeUndefined(); }); + +test('supports legacy rendering', () => { + render(, { concurrentRendering: false }); + expect(screen.root).toBeDefined(); +}); + +test('supports concurrent rendering', () => { + render(, { concurrentRendering: true }); + expect(screen.root).toBeDefined(); +}); diff --git a/src/config.ts b/src/config.ts index 479f14255..663705570 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,11 +15,10 @@ export type Config = { defaultDebugOptions?: Partial; /** - * Only works if used with React 18. - * Set to `true` if you want to force synchronous rendering. - * Otherwise `render` will default to concurrent React if available. + * Set to `true` to enable concurrent rendering. + * Otherwise `render` will default to legacy synchronous rendering. */ - legacyRoot: boolean; + concurrentRendering: boolean; }; export type ConfigAliasOptions = { @@ -44,7 +43,7 @@ export type InternalConfig = Config & { const defaultConfig: InternalConfig = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - legacyRoot: true, + concurrentRendering: false, }; let config = { ...defaultConfig }; diff --git a/src/render.tsx b/src/render.tsx index 0de68c218..f202ee793 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -25,11 +25,10 @@ export interface RenderOptions { wrapper?: React.ComponentType; /** - * Only works if used with React 18. - * Set to `true` if you want to force synchronous rendering. - * Otherwise `render` will default to concurrent React if available. + * Set to `true` to enable concurrent rendering. + * Otherwise `render` will default to legacy synchronous rendering. */ - legacyRoot?: boolean | undefined; + concurrentRendering?: boolean | undefined; createNodeMock?: (element: React.ReactElement) => unknown; unstable_validateStringsRenderedWithinText?: boolean; @@ -55,7 +54,7 @@ export function renderInternal( ) { const { wrapper: Wrapper, - legacyRoot, + concurrentRendering: concurrent, detectHostComponentNames = true, unstable_validateStringsRenderedWithinText, ...rest @@ -63,7 +62,7 @@ export function renderInternal( const testRendererOptions: TestRendererOptions = { // @ts-expect-error incomplete typing on RTR package - unstable_isConcurrent: !(legacyRoot ?? getConfig().legacyRoot), + unstable_isConcurrent: concurrent ?? getConfig().concurrentRendering, ...rest, }; From e981e0297004d9bbc55a00ce0b0ba94eec0a7515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 11:33:53 +0200 Subject: [PATCH 03/16] chore: add docs --- website/docs/12.x/docs/api/misc/config.mdx | 5 +++++ website/docs/12.x/docs/api/render.mdx | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/website/docs/12.x/docs/api/misc/config.mdx b/website/docs/12.x/docs/api/misc/config.mdx index 049e82760..a9ed7f6eb 100644 --- a/website/docs/12.x/docs/api/misc/config.mdx +++ b/website/docs/12.x/docs/api/misc/config.mdx @@ -7,6 +7,7 @@ type Config = { asyncUtilTimeout: number; defaultHidden: boolean; defaultDebugOptions: Partial; + concurrentRendering: boolean; }; function configure(options: Partial) {} @@ -26,6 +27,10 @@ This option is also available as `defaultHidden` alias for compatibility with [R Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`. +### `concurrentRendering` option + +Set to `true` to enable concurrent rendering used in the React Native New Architecture. Otherwise `render` will default to legacy synchronous rendering. + ## `resetToDefaults()` ```ts diff --git a/website/docs/12.x/docs/api/render.mdx b/website/docs/12.x/docs/api/render.mdx index 0763e1dfd..821439621 100644 --- a/website/docs/12.x/docs/api/render.mdx +++ b/website/docs/12.x/docs/api/render.mdx @@ -32,6 +32,10 @@ wrapper?: React.ComponentType, This option allows you to wrap the tested component, passed as the first option to the `render()` function, in an additional wrapper component. This is useful for creating reusable custom render functions for common React Context providers. +#### `concurrentRendering` option + +Set to `true` to enable concurrent rendering used in the React Native New Architecture. Otherwise `render` will default to legacy synchronous rendering. + #### `createNodeMock` option ```ts From 0756fd6afa50b876af074c340cd2132e3b77c7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 11:36:14 +0200 Subject: [PATCH 04/16] refactor: self code review --- jest-setup.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jest-setup.ts b/jest-setup.ts index d407a394f..a4d893a18 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,7 +1,6 @@ -import { resetToDefaults, configure } from './src/pure'; +import { resetToDefaults } from './src/pure'; import './src/matchers/extend-expect'; beforeEach(() => { resetToDefaults(); - configure({ concurrentRendering: false }); }); From cb8eaea7df8dbc4a087027490f36ee9d2e1ea824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 13:37:21 +0200 Subject: [PATCH 05/16] refactor: rename to `concurrentRoot` --- src/__tests__/config.test.ts | 2 +- src/__tests__/render.test.tsx | 4 ++-- src/config.ts | 4 ++-- src/render.tsx | 6 +++--- website/docs/12.x/docs/api/misc/config.mdx | 4 ++-- website/docs/12.x/docs/api/render.mdx | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 2ca58416f..b3d2a7ed1 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -16,7 +16,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - concurrentRendering: false, + concurrentRoot: false, }); }); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index ee84d1e0f..3127963d7 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -243,11 +243,11 @@ test('render calls detects host component names', () => { }); test('supports legacy rendering', () => { - render(, { concurrentRendering: false }); + render(, { concurrentRoot: false }); expect(screen.root).toBeDefined(); }); test('supports concurrent rendering', () => { - render(, { concurrentRendering: true }); + render(, { concurrentRoot: true }); expect(screen.root).toBeDefined(); }); diff --git a/src/config.ts b/src/config.ts index 663705570..388933cdd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,7 @@ export type Config = { * Set to `true` to enable concurrent rendering. * Otherwise `render` will default to legacy synchronous rendering. */ - concurrentRendering: boolean; + concurrentRoot: boolean; }; export type ConfigAliasOptions = { @@ -43,7 +43,7 @@ export type InternalConfig = Config & { const defaultConfig: InternalConfig = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - concurrentRendering: false, + concurrentRoot: false, }; let config = { ...defaultConfig }; diff --git a/src/render.tsx b/src/render.tsx index f202ee793..ee9e25160 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -28,7 +28,7 @@ export interface RenderOptions { * Set to `true` to enable concurrent rendering. * Otherwise `render` will default to legacy synchronous rendering. */ - concurrentRendering?: boolean | undefined; + concurrentRoot?: boolean | undefined; createNodeMock?: (element: React.ReactElement) => unknown; unstable_validateStringsRenderedWithinText?: boolean; @@ -54,7 +54,7 @@ export function renderInternal( ) { const { wrapper: Wrapper, - concurrentRendering: concurrent, + concurrentRoot, detectHostComponentNames = true, unstable_validateStringsRenderedWithinText, ...rest @@ -62,7 +62,7 @@ export function renderInternal( const testRendererOptions: TestRendererOptions = { // @ts-expect-error incomplete typing on RTR package - unstable_isConcurrent: concurrent ?? getConfig().concurrentRendering, + unstable_isConcurrent: concurrentRoot ?? getConfig().concurrentRoot, ...rest, }; diff --git a/website/docs/12.x/docs/api/misc/config.mdx b/website/docs/12.x/docs/api/misc/config.mdx index a9ed7f6eb..bdaabd107 100644 --- a/website/docs/12.x/docs/api/misc/config.mdx +++ b/website/docs/12.x/docs/api/misc/config.mdx @@ -7,7 +7,7 @@ type Config = { asyncUtilTimeout: number; defaultHidden: boolean; defaultDebugOptions: Partial; - concurrentRendering: boolean; + concurrentRoot: boolean; }; function configure(options: Partial) {} @@ -27,7 +27,7 @@ This option is also available as `defaultHidden` alias for compatibility with [R Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`. -### `concurrentRendering` option +### `concurrentRoot` option Set to `true` to enable concurrent rendering used in the React Native New Architecture. Otherwise `render` will default to legacy synchronous rendering. diff --git a/website/docs/12.x/docs/api/render.mdx b/website/docs/12.x/docs/api/render.mdx index 821439621..ee38878ff 100644 --- a/website/docs/12.x/docs/api/render.mdx +++ b/website/docs/12.x/docs/api/render.mdx @@ -32,7 +32,7 @@ wrapper?: React.ComponentType, This option allows you to wrap the tested component, passed as the first option to the `render()` function, in an additional wrapper component. This is useful for creating reusable custom render functions for common React Context providers. -#### `concurrentRendering` option +#### `concurrentRoot` option Set to `true` to enable concurrent rendering used in the React Native New Architecture. Otherwise `render` will default to legacy synchronous rendering. From dc404011a3736e290d2a22aeb8332adc4e937cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 13:45:59 +0200 Subject: [PATCH 06/16] refactor: self code review --- src/render.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/render.tsx b/src/render.tsx index ee9e25160..e4a6e22e4 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -61,9 +61,9 @@ export function renderInternal( } = options || {}; const testRendererOptions: TestRendererOptions = { + ...rest, // @ts-expect-error incomplete typing on RTR package unstable_isConcurrent: concurrentRoot ?? getConfig().concurrentRoot, - ...rest, }; if (detectHostComponentNames) { From 85ae967d426aeb2d689f7104ceb165949af043a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 13:51:29 +0200 Subject: [PATCH 07/16] chore: run tests in both legacy and concurrent mode on the CI --- .github/workflows/ci.yml | 17 +++++++++++++++++ jest-setup.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bc937e29..f4b5daacb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,23 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 + - name: Test in concurrent mode + run: CONCURRENT_MODE=1 yarn test:ci + + test-concurrent: + needs: [install-cache-deps] + runs-on: ubuntu-latest + name: Test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + + - name: Test in concurrent mode + run: CONCURRENT_MODE=1 yarn test:ci + test-website: runs-on: ubuntu-latest name: Test Website diff --git a/jest-setup.ts b/jest-setup.ts index a4d893a18..9ed60181d 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,6 +1,9 @@ -import { resetToDefaults } from './src/pure'; +import { resetToDefaults, configure } from './src/pure'; import './src/matchers/extend-expect'; beforeEach(() => { resetToDefaults(); + if (process.env.CONCURRENT_MODE === '1') { + configure({ concurrentRoot: true }); + } }); From 2cbcc4d302dfc41aaa52b877c2c77b483b3564ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 13:53:22 +0200 Subject: [PATCH 08/16] chore: tweak --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4b5daacb..7916d9b62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: test-concurrent: needs: [install-cache-deps] runs-on: ubuntu-latest - name: Test + name: Test (concurrent mode) steps: - name: Checkout uses: actions/checkout@v4 From 4cf9b4e7fd22d38d19926641be531bacf3c0f5d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 13:55:56 +0200 Subject: [PATCH 09/16] chore: tweaks --- .github/workflows/ci.yml | 4 +--- package.json | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7916d9b62..3ec3a6626 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,13 +56,11 @@ jobs: uses: ./.github/actions/setup-deps - name: Test - run: yarn test:ci + run: yarn test:ci:coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 - - name: Test in concurrent mode - run: CONCURRENT_MODE=1 yarn test:ci test-concurrent: needs: [install-cache-deps] diff --git a/package.json b/package.json index f3ab481d1..de469551f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "scripts": { "clean": "del build", "test": "jest", - "test:ci": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8", + "test:ci:": "jest --maxWorkers=2", + "test:ci:coverage": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8", "typecheck": "tsc", "copy-flowtypes": "cp typings/index.flow.js build", "lint": "eslint src --cache", From 8b9b939b949af942ea66b1e34bc9ec4edeb10336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 13:58:11 +0200 Subject: [PATCH 10/16] chore: fix ci --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de469551f..dfce46bef 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "scripts": { "clean": "del build", "test": "jest", - "test:ci:": "jest --maxWorkers=2", + "test:ci": "jest --maxWorkers=2", "test:ci:coverage": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8", "typecheck": "tsc", "copy-flowtypes": "cp typings/index.flow.js build", From 902811c4fb4e6d780347cdff73032e48654563f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 14:08:41 +0200 Subject: [PATCH 11/16] chore: test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ec3a6626..269e201b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: uses: ./.github/actions/setup-deps - name: Test in concurrent mode - run: CONCURRENT_MODE=1 yarn test:ci + run: CONCURRENT_MODE=1 yarn test:ci:coverage test-website: runs-on: ubuntu-latest From c5a96278f386f9c1e3834da39ffa03b778aed0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 24 Oct 2024 14:11:01 +0200 Subject: [PATCH 12/16] chore: revert --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 269e201b3..3ec3a6626 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: uses: ./.github/actions/setup-deps - name: Test in concurrent mode - run: CONCURRENT_MODE=1 yarn test:ci:coverage + run: CONCURRENT_MODE=1 yarn test:ci test-website: runs-on: ubuntu-latest From a540083f8bb5a74b3139aeba93179086c5aa2a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 25 Oct 2024 22:20:41 +0200 Subject: [PATCH 13/16] chore: pressable experiment --- experiments-app/src/experiments.ts | 6 ++ experiments-app/src/screens/PressEvents.tsx | 82 +++++++++++++++++++++ experiments-app/src/utils/helpers.ts | 5 +- src/__tests__/render.test.tsx | 15 +++- 4 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 experiments-app/src/screens/PressEvents.tsx diff --git a/experiments-app/src/experiments.ts b/experiments-app/src/experiments.ts index fdd485148..af889cfb1 100644 --- a/experiments-app/src/experiments.ts +++ b/experiments-app/src/experiments.ts @@ -1,4 +1,5 @@ import { AccessibilityScreen } from './screens/Accessibility'; +import { PressEvents } from './screens/PressEvents'; import { TextInputEventPropagation } from './screens/TextInputEventPropagation'; import { TextInputEvents } from './screens/TextInputEvents'; import { ScrollViewEvents } from './screens/ScrollViewEvents'; @@ -13,6 +14,11 @@ export const experiments = [ title: 'Accessibility', component: AccessibilityScreen, }, + { + key: 'PressEvents', + title: 'Press Events', + component: PressEvents, + }, { key: 'TextInputEvents', title: 'TextInput Events', diff --git a/experiments-app/src/screens/PressEvents.tsx b/experiments-app/src/screens/PressEvents.tsx new file mode 100644 index 000000000..a8ba3edcc --- /dev/null +++ b/experiments-app/src/screens/PressEvents.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { + StyleSheet, + SafeAreaView, + Text, + TextInput, + View, + Pressable, + TouchableOpacity, +} from 'react-native'; +import { nativeEventLogger, logEvent } from '../utils/helpers'; + +export function PressEvents() { + const [value, setValue] = React.useState(''); + + const handleChangeText = (value: string) => { + setValue(value); + logEvent('changeText', value); + }; + + return ( + + + + + + + Text + + + + + Pressable + + + + + Pressable + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + wrapper: { + padding: 20, + backgroundColor: 'yellow', + }, + textInput: { + backgroundColor: 'white', + margin: 20, + padding: 8, + fontSize: 18, + borderWidth: 1, + borderColor: 'grey', + }, +}); diff --git a/experiments-app/src/utils/helpers.ts b/experiments-app/src/utils/helpers.ts index 5993a46c4..1366177b7 100644 --- a/experiments-app/src/utils/helpers.ts +++ b/experiments-app/src/utils/helpers.ts @@ -1,5 +1,7 @@ import { NativeSyntheticEvent } from 'react-native/types'; +let lastEventTimeStamp: number | null = null; + export function nativeEventLogger(name: string) { return (event: NativeSyntheticEvent) => { logEvent(name, event?.nativeEvent); @@ -14,5 +16,6 @@ export function customEventLogger(name: string) { export function logEvent(name: string, ...args: unknown[]) { // eslint-disable-next-line no-console - console.log(`Event: ${name}`, ...args); + console.log(`[${Date.now() - (lastEventTimeStamp ?? Date.now())}ms] Event: ${name}`, ...args); + lastEventTimeStamp = Date.now(); } diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 3127963d7..327b7189e 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import * as React from 'react'; import { Pressable, Text, TextInput, View } from 'react-native'; -import { getConfig, resetToDefaults } from '../config'; +import { configure, getConfig, resetToDefaults } from '../config'; import { fireEvent, render, RenderAPI, screen } from '..'; const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; @@ -247,7 +247,16 @@ test('supports legacy rendering', () => { expect(screen.root).toBeDefined(); }); -test('supports concurrent rendering', () => { +// Enable concurrent rendering globally +configure({ concurrentRoot: true }); + +test('globally enable concurrent rendering', () => { + render(); + expect(screen.root).toBeOnTheScreen(); +}); + +// Enable concurrent rendering locally +test('locally enable concurrent rendering', () => { render(, { concurrentRoot: true }); - expect(screen.root).toBeDefined(); + expect(screen.root).toBeOnTheScreen(); }); From 76f4323b4ba75e7f2757a15b63e518f374954615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 25 Oct 2024 22:24:45 +0200 Subject: [PATCH 14/16] refactor: update press implementation --- src/user-event/press/press.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index c49ea7090..6de77c402 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -118,11 +118,19 @@ async function emitTextPressEvents( await wait(config); dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); - // Emit either `press` or `longPress`. - dispatchEvent(element, options.type, EventBuilder.Common.touch()); - await wait(config, options.duration); + + // Long press events are emitted before `pressOut`. + if (options.type === 'longPress') { + dispatchEvent(element, 'longPress', EventBuilder.Common.touch()); + } + dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + + // Regular press events are emitted after `pressOut`. + if (options.type === 'press') { + dispatchEvent(element, 'press', EventBuilder.Common.touch()); + } } /** From 3eee37f0318e275afa4a929438e802c581737fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 25 Oct 2024 22:31:04 +0200 Subject: [PATCH 15/16] chore: fix Text press event order --- src/user-event/press/__tests__/press.real-timers.test.tsx | 8 ++++---- src/user-event/press/__tests__/press.test.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index 1ba53c27e..4844d7d41 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -198,7 +198,7 @@ describe('userEvent.press with real timers', () => { ); await userEvent.press(screen.getByText('press me')); - expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); test('does not trigger on disabled Text', async () => { @@ -240,7 +240,7 @@ describe('userEvent.press with real timers', () => { expect(events).toEqual([]); }); - test('works on TetInput', async () => { + test('works on TextInput', async () => { const { events, logEvent } = createEventLogger(); render( @@ -255,7 +255,7 @@ describe('userEvent.press with real timers', () => { expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut']); }); - test('does not call onPressIn and onPressOut on non editable TetInput', async () => { + test('does not call onPressIn and onPressOut on non editable TextInput', async () => { const { events, logEvent } = createEventLogger(); render( @@ -270,7 +270,7 @@ describe('userEvent.press with real timers', () => { expect(events).toEqual([]); }); - test('does not call onPressIn and onPressOut on TetInput with pointer events disabled', async () => { + test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => { const { events, logEvent } = createEventLogger(); render( diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index c4ff8be74..e830eacc6 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -199,7 +199,7 @@ describe('userEvent.press with fake timers', () => { ); await userEvent.press(screen.getByText('press me')); - expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); test('press works on Button', async () => { From 7dc6e229e14e4ff8c4fd5cf9ae127cf89a20ed4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 25 Oct 2024 22:34:18 +0200 Subject: [PATCH 16/16] chore: fix Pressable implementation --- .../__snapshots__/press.test.tsx.snap | 8 ++--- .../__tests__/press.real-timers.test.tsx | 4 +-- src/user-event/press/__tests__/press.test.tsx | 2 +- src/user-event/press/constants.ts | 7 ----- src/user-event/press/press.ts | 29 ++++++++++--------- 5 files changed, 22 insertions(+), 28 deletions(-) delete mode 100644 src/user-event/press/constants.ts diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap index ceb2803f3..068aebacd 100644 --- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap +++ b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap @@ -33,7 +33,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu }, }, { - "name": "press", + "name": "pressOut", "payload": { "currentTarget": { "measure": [Function], @@ -52,7 +52,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 130, "touches": [], }, "persist": [Function], @@ -63,7 +63,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu }, }, { - "name": "pressOut", + "name": "press", "payload": { "currentTarget": { "measure": [Function], @@ -82,7 +82,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 130, "touches": [], }, "persist": [Function], diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index 4844d7d41..930bdff0a 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -32,7 +32,7 @@ describe('userEvent.press with real timers', () => { ); await user.press(screen.getByTestId('pressable')); - expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); test('does not trigger event when pressable is disabled', async () => { @@ -128,7 +128,7 @@ describe('userEvent.press with real timers', () => { ); await user.press(screen.getByTestId('pressable')); - expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); test('crawls up in the tree to find an element that responds to touch events', async () => { diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index e830eacc6..43ce21c67 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -129,7 +129,7 @@ describe('userEvent.press with fake timers', () => { ); await user.press(screen.getByTestId('pressable')); - expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); test('crawls up in the tree to find an element that responds to touch events', async () => { diff --git a/src/user-event/press/constants.ts b/src/user-event/press/constants.ts deleted file mode 100644 index 8d237a0db..000000000 --- a/src/user-event/press/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -// These are constants defined in the React Native repo - -// Used to define the delay before calling onPressOut after a press -export const DEFAULT_MIN_PRESS_DURATION = 130; - -// Default minimum press duration to trigger a long press -export const DEFAULT_LONG_PRESS_DELAY_MS = 500; diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 6de77c402..d63df7d17 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,5 +1,4 @@ import { ReactTestInstance } from 'react-test-renderer'; -import act from '../../act'; import { getHostParent } from '../../helpers/component-tree'; import { isTextInputEditable } from '../../helpers/text-input'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; @@ -7,7 +6,10 @@ import { isHostText, isHostTextInput } from '../../helpers/host-component-names' import { EventBuilder } from '../event-builder'; import { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, wait } from '../utils'; -import { DEFAULT_MIN_PRESS_DURATION } from './constants'; + +// These are constants defined in the React Native repo +export const DEFAULT_MIN_PRESS_DURATION = 130; +export const DEFAULT_LONG_PRESS_DELAY_MS = 500; export interface PressOptions { duration?: number; @@ -27,7 +29,7 @@ export async function longPress( ): Promise { await basePress(this.config, element, { type: 'longPress', - duration: options?.duration ?? 500, + duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS, }); } @@ -73,18 +75,14 @@ const emitPressablePressEvents = async ( dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant()); - await wait(config, options.duration); + // We apply minimum press duration here to ensure that `press` events are emitted after `pressOut`. + // Otherwise, pressables would emit them in the reverse order, which in reality happens only for + // very short presses (< 130ms) and contradicts the React Native docs. + // See: https://reactnative.dev/docs/pressable#onpress + let duration = Math.max(options.duration, DEFAULT_MIN_PRESS_DURATION); + await wait(config, duration); dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease()); - - // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION - // before emitting the `pressOut` event. We need to wait here, so that - // `press()` function does not return before that. - if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) { - await act(async () => { - await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); - }); - } }; const isEnabledTouchResponder = (element: ReactTestInstance) => { @@ -127,7 +125,10 @@ async function emitTextPressEvents( dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); - // Regular press events are emitted after `pressOut`. + // Regular press events are emitted after `pressOut` according to the React Native docs. + // See: https://reactnative.dev/docs/pressable#onpress + // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but + // we will ignore that as in reality most pressed would be above the 130ms threshold. if (options.type === 'press') { dispatchEvent(element, 'press', EventBuilder.Common.touch()); }