From 16cd4234e117babe79c3430d6eadb51d55a12f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 28 Jul 2026 10:38:59 +0200 Subject: [PATCH 1/5] refactor: extract Image.ios.js props once ^ Conflicts: ^ packages/react-native/Libraries/Image/Image.ios.js --- .../react-native/Libraries/Image/Image.ios.js | 157 +++++++++++------- 1 file changed, 99 insertions(+), 58 deletions(-) diff --git a/packages/react-native/Libraries/Image/Image.ios.js b/packages/react-native/Libraries/Image/Image.ios.js index 3fc14f2bcf14..e99a2888ff31 100644 --- a/packages/react-native/Libraries/Image/Image.ios.js +++ b/packages/react-native/Libraries/Image/Image.ios.js @@ -29,6 +29,7 @@ import ImageViewNativeComponent from './ImageViewNativeComponent'; import NativeImageLoaderIOS from './NativeImageLoaderIOS'; import resolveAssetSource from './resolveAssetSource'; import * as React from 'react'; +import {use} from 'react'; export type ImageInstance = HostInstance; @@ -111,90 +112,130 @@ async function queryCache( */ let BaseImage: AbstractImageIOS = ({ ref: forwardedRef, - ...props + accessible, + accessibilityLabel, + accessibilityState, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + 'aria-selected': ariaSelected, + alt, + children, + crossOrigin, + height, + referrerPolicy, + resizeMode, + source, + src, + srcSet, + style, + tintColor, + width, + ...restProps }: { ref?: React.RefSetter, ...ImageProps, }) => { - const source = getImageSourcesFromImageProps(props) || { + const resolvedSource = getImageSourcesFromImageProps({ + crossOrigin, + height, + referrerPolicy, + source, + src, + srcSet, + width, + }) || { uri: undefined, width: undefined, height: undefined, }; - let style: ImageStyleProp; + let resolvedStyle: ImageStyleProp; let sources; - if (Array.isArray(source)) { - style = [styles.base, props.style]; - sources = source; + if (Array.isArray(resolvedSource)) { + resolvedStyle = [styles.base, style]; + sources = resolvedSource; } else { - const {uri} = source; + const {uri} = resolvedSource; if (uri === '') { console.warn('source.uri should not be an empty string'); } - const width = source.width ?? props.width; - const height = source.height ?? props.height; - style = [{width, height}, styles.base, props.style]; - sources = [source]; + resolvedStyle = [ + { + width: resolvedSource.width ?? width, + height: resolvedSource.height ?? height, + }, + styles.base, + style, + ]; + sources = [resolvedSource]; } - const flattenedStyle = flattenStyle(style); - const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit); - const resizeMode = - objectFit || props.resizeMode || flattenedStyle?.resizeMode || 'cover'; - const tintColor = props.tintColor ?? flattenedStyle?.tintColor; - - if (props.children != null) { + if (children != null) { throw new Error( 'The component cannot contain children. If you want to render content on top of the image, consider using the component or absolute positioning.', ); } - const { - 'aria-busy': ariaBusy, - 'aria-checked': ariaChecked, - 'aria-disabled': ariaDisabled, - 'aria-expanded': ariaExpanded, - 'aria-selected': ariaSelected, - 'aria-hidden': ariaHidden, - src, - ...restProps - } = props; - const _accessibilityState = { - busy: ariaBusy ?? props.accessibilityState?.busy, - checked: ariaChecked ?? props.accessibilityState?.checked, - disabled: ariaDisabled ?? props.accessibilityState?.disabled, - expanded: ariaExpanded ?? props.accessibilityState?.expanded, - selected: ariaSelected ?? props.accessibilityState?.selected, + const resolvedProps = restProps as { + ...React.PropsOf, }; - // In order for `aria-hidden` to work on iOS we must set `accessible` to false (`accessibilityElementsHidden` is not enough). - const accessible = - ariaHidden !== true && (props.alt !== undefined ? true : props.accessible); - const accessibilityLabel = props['aria-label'] ?? props.accessibilityLabel; + resolvedProps.style = resolvedStyle; + resolvedProps.source = sources; + + const flattenedStyle = flattenStyle(resolvedStyle); + const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit); + resolvedProps.resizeMode = + objectFit || resizeMode || flattenedStyle?.resizeMode || 'cover'; + resolvedProps.tintColor = tintColor ?? flattenedStyle?.tintColor; + + if (ariaLabel != null) { + resolvedProps.accessibilityLabel = ariaLabel; + } else if (accessibilityLabel != null) { + resolvedProps.accessibilityLabel = accessibilityLabel; + } else if (alt != null) { + resolvedProps.accessibilityLabel = alt; + } + + if (ariaHidden === true) { + // In order for `aria-hidden` to work on iOS we must set `accessible` to + // false (`accessibilityElementsHidden` is not enough). + resolvedProps.accessible = false; + } else if (alt !== undefined) { + resolvedProps.accessible = true; + } else if (accessible != null) { + resolvedProps.accessible = accessible; + } + + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ) { + resolvedProps.accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef); - return ( - - {analyticTag => { - return ( - - ); - }} - - ); + const analyticTag = use(ImageAnalyticsTagContext); + if (analyticTag != null) { + resolvedProps.internal_analyticTag = analyticTag; + } + + return ; }; const imageComponentDecorator = unstable_getImageComponentDecorator(); From ae90b3069126435d17c751af02e09a6b9b3684aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 29 Jul 2026 14:53:31 +0200 Subject: [PATCH 2/5] tests --- .../Libraries/Image/__tests__/Image-itest.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/react-native/Libraries/Image/__tests__/Image-itest.js b/packages/react-native/Libraries/Image/__tests__/Image-itest.js index 33ac94c94566..aed5e2f1a570 100644 --- a/packages/react-native/Libraries/Image/__tests__/Image-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/Image-itest.js @@ -648,6 +648,66 @@ describe('', () => { }); }); + describe('aria-* accessibility state', () => { + ( + [ + [, 'busy:true'], + [, 'disabled:true'], + [, 'expanded:true'], + [, 'selected:true'], + [, 'checked:Checked'], + ] as const + ).forEach(([element, expected]) => { + it(`maps ${expected.split(':')[0]} into accessibilityState`, () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(element); + }); + + expect( + root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props.accessibilityState, + ).toContain(expected); + }); + }); + + it('takes precedence over the matching accessibilityState field', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const accessibilityState = root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props.accessibilityState; + + expect(accessibilityState).toContain('busy:true'); + // Fields not covered by an `aria-*` prop are preserved. + expect(accessibilityState).toContain('disabled:true'); + }); + + it('is not set when no state props are provided', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual(); + }); + }); + component TestComponent(testID?: ?string, ...props: AccessibilityProps) { return ; } From ed4718d875287eed078d290a48d84198bf5b908f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 30 Jul 2026 12:52:08 +0200 Subject: [PATCH 3/5] refactor tests --- .../Touchable/TouchableHighlight.js | 12 ++- .../react-native/Libraries/Image/Image.ios.js | 2 +- .../Libraries/Image/__tests__/Image-itest.js | 52 ++---------- .../utilities/accessibilityPropsSuite.js | 81 +++++++++++++++++++ 4 files changed, 97 insertions(+), 50 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js index bdb0d4660de3..e2d3cf9d3254 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js @@ -230,11 +230,15 @@ class TouchableHighlightImpl extends React.Component< const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} = this.state.pressability.getEventHandlers(); + // The `disabled` prop takes precedence over `aria-disabled`, which in turn + // takes precedence over `accessibilityState.disabled`. The remaining + // `aria-*` state props are forwarded to `View`, which merges them. + const disabled = this.props.disabled ?? this.props['aria-disabled']; const accessibilityState: ?AccessibilityState = - this.props.disabled != null + disabled != null ? { ...this.props.accessibilityState, - disabled: this.props.disabled, + disabled, } : this.props.accessibilityState; @@ -260,6 +264,10 @@ class TouchableHighlightImpl extends React.Component< accessibilityLanguage={this.props.accessibilityLanguage} accessibilityRole={this.props.accessibilityRole} accessibilityState={accessibilityState} + aria-busy={this.props['aria-busy']} + aria-checked={this.props['aria-checked']} + aria-expanded={this.props['aria-expanded']} + aria-selected={this.props['aria-selected']} accessibilityValue={accessibilityValue} accessibilityActions={this.props.accessibilityActions} onAccessibilityAction={this.props.onAccessibilityAction} diff --git a/packages/react-native/Libraries/Image/Image.ios.js b/packages/react-native/Libraries/Image/Image.ios.js index e99a2888ff31..5998f5ba4c9a 100644 --- a/packages/react-native/Libraries/Image/Image.ios.js +++ b/packages/react-native/Libraries/Image/Image.ios.js @@ -205,7 +205,7 @@ let BaseImage: AbstractImageIOS = ({ // In order for `aria-hidden` to work on iOS we must set `accessible` to // false (`accessibilityElementsHidden` is not enough). resolvedProps.accessible = false; - } else if (alt !== undefined) { + } else if (alt != null) { resolvedProps.accessible = true; } else if (accessible != null) { resolvedProps.accessible = accessible; diff --git a/packages/react-native/Libraries/Image/__tests__/Image-itest.js b/packages/react-native/Libraries/Image/__tests__/Image-itest.js index aed5e2f1a570..4d3bc4b1a560 100644 --- a/packages/react-native/Libraries/Image/__tests__/Image-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/Image-itest.js @@ -648,53 +648,11 @@ describe('', () => { }); }); - describe('aria-* accessibility state', () => { - ( - [ - [, 'busy:true'], - [, 'disabled:true'], - [, 'expanded:true'], - [, 'selected:true'], - [, 'checked:Checked'], - ] as const - ).forEach(([element, expected]) => { - it(`maps ${expected.split(':')[0]} into accessibilityState`, () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(element); - }); - - expect( - root - .getRenderedOutput({props: ['accessibilityState']}) - .toJSONObject().props.accessibilityState, - ).toContain(expected); - }); - }); - - it('takes precedence over the matching accessibilityState field', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render( - , - ); - }); - - const accessibilityState = root - .getRenderedOutput({props: ['accessibilityState']}) - .toJSONObject().props.accessibilityState; - - expect(accessibilityState).toContain('busy:true'); - // Fields not covered by an `aria-*` prop are preserved. - expect(accessibilityState).toContain('disabled:true'); - }); - + describe('accessibilityState', () => { + // The `aria-*` to `accessibilityState` mapping is covered by + // `accessibilityPropsSuite`. This case cannot live there, because + // `Pressable` and `TouchableWithoutFeedback` always emit + // `accessibilityState`. it('is not set when no state props are provided', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js b/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js index 62933747b3d5..26df462fe089 100644 --- a/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js +++ b/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js @@ -310,6 +310,87 @@ export default function accessibilityPropsSuite( ).toEqual(true); }); }); + + describe('aria-* aliases', () => { + it('\'aria-busy\' maps to "busy"', () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('busy:true'), + ).toEqual(true); + }); + + it('\'aria-disabled\' maps to "disabled"', () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('disabled:true'), + ).toEqual(true); + }); + + it('\'aria-expanded\' maps to "expanded"', () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('expanded:true'), + ).toEqual(true); + }); + + it('\'aria-selected\' maps to "selected"', () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('selected:true'), + ).toEqual(true); + }); + + describe('\'aria-checked\' maps to "checked"', () => { + it('when set to true', () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('checked:Checked'), + ).toEqual(true); + }); + + it('when set to false', () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('checked:Unchecked'), + ).toEqual(true); + }); + + it("when set to 'mixed'", () => { + expect( + getAccessibilityProp( + , + 'accessibilityState', + ).includes('checked:Mixed'), + ).toEqual(true); + }); + }); + + it('take precedence over the matching `accessibilityState` field', () => { + const accessibilityState = getAccessibilityProp( + , + 'accessibilityState', + ); + + expect(accessibilityState.includes('busy:true')).toEqual(true); + // Fields not covered by an `aria-*` prop are preserved. + expect(accessibilityState.includes('disabled:true')).toEqual(true); + }); + }); }); describe('accessibilityActions', () => { From 7fe83a5a41aee3b959b8ef7971f1968c10715cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 30 Jul 2026 13:06:19 +0200 Subject: [PATCH 4/5] cleanup --- .../Touchable/TouchableHighlight.js | 12 +-- .../Libraries/Image/__tests__/Image-itest.js | 74 +++++++++++++++-- .../utilities/accessibilityPropsSuite.js | 81 ------------------- 3 files changed, 71 insertions(+), 96 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js index e2d3cf9d3254..bdb0d4660de3 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js @@ -230,15 +230,11 @@ class TouchableHighlightImpl extends React.Component< const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} = this.state.pressability.getEventHandlers(); - // The `disabled` prop takes precedence over `aria-disabled`, which in turn - // takes precedence over `accessibilityState.disabled`. The remaining - // `aria-*` state props are forwarded to `View`, which merges them. - const disabled = this.props.disabled ?? this.props['aria-disabled']; const accessibilityState: ?AccessibilityState = - disabled != null + this.props.disabled != null ? { ...this.props.accessibilityState, - disabled, + disabled: this.props.disabled, } : this.props.accessibilityState; @@ -264,10 +260,6 @@ class TouchableHighlightImpl extends React.Component< accessibilityLanguage={this.props.accessibilityLanguage} accessibilityRole={this.props.accessibilityRole} accessibilityState={accessibilityState} - aria-busy={this.props['aria-busy']} - aria-checked={this.props['aria-checked']} - aria-expanded={this.props['aria-expanded']} - aria-selected={this.props['aria-selected']} accessibilityValue={accessibilityValue} accessibilityActions={this.props.accessibilityActions} onAccessibilityAction={this.props.onAccessibilityAction} diff --git a/packages/react-native/Libraries/Image/__tests__/Image-itest.js b/packages/react-native/Libraries/Image/__tests__/Image-itest.js index 4d3bc4b1a560..a1f3140f850b 100644 --- a/packages/react-native/Libraries/Image/__tests__/Image-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/Image-itest.js @@ -649,21 +649,85 @@ describe('', () => { }); describe('accessibilityState', () => { - // The `aria-*` to `accessibilityState` mapping is covered by - // `accessibilityPropsSuite`. This case cannot live there, because - // `Pressable` and `TouchableWithoutFeedback` always emit - // `accessibilityState`. + function getAccessibilityState(element: React.MixedElement) { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(element); + }); + + return root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props.accessibilityState; + } + it('is not set when no state props are provided', () => { const root = Fantom.createRoot(); Fantom.runTask(() => { - root.render(); + root.render(); }); expect( root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), ).toEqual(); }); + + it('maps \'aria-busy\' to "busy"', () => { + expect(getAccessibilityState()).toContain( + 'busy:true', + ); + }); + + it('maps \'aria-disabled\' to "disabled"', () => { + expect(getAccessibilityState()).toContain( + 'disabled:true', + ); + }); + + it('maps \'aria-expanded\' to "expanded"', () => { + expect(getAccessibilityState()).toContain( + 'expanded:true', + ); + }); + + it('maps \'aria-selected\' to "selected"', () => { + expect(getAccessibilityState()).toContain( + 'selected:true', + ); + }); + + describe('maps \'aria-checked\' to "checked"', () => { + it('when set to true', () => { + expect( + getAccessibilityState(), + ).toContain('checked:Checked'); + }); + + it('when set to false', () => { + expect( + getAccessibilityState(), + ).toContain('checked:Unchecked'); + }); + + it("when set to 'mixed'", () => { + expect( + getAccessibilityState(), + ).toContain('checked:Mixed'); + }); + }); + + it('gives `aria-*` precedence over the matching field', () => { + const accessibilityState = getAccessibilityState( + , + ); + + expect(accessibilityState).toContain('busy:true'); + expect(accessibilityState).toContain('disabled:true'); + }); }); component TestComponent(testID?: ?string, ...props: AccessibilityProps) { diff --git a/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js b/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js index 26df462fe089..62933747b3d5 100644 --- a/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js +++ b/packages/react-native/src/private/__tests__/utilities/accessibilityPropsSuite.js @@ -310,87 +310,6 @@ export default function accessibilityPropsSuite( ).toEqual(true); }); }); - - describe('aria-* aliases', () => { - it('\'aria-busy\' maps to "busy"', () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('busy:true'), - ).toEqual(true); - }); - - it('\'aria-disabled\' maps to "disabled"', () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('disabled:true'), - ).toEqual(true); - }); - - it('\'aria-expanded\' maps to "expanded"', () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('expanded:true'), - ).toEqual(true); - }); - - it('\'aria-selected\' maps to "selected"', () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('selected:true'), - ).toEqual(true); - }); - - describe('\'aria-checked\' maps to "checked"', () => { - it('when set to true', () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('checked:Checked'), - ).toEqual(true); - }); - - it('when set to false', () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('checked:Unchecked'), - ).toEqual(true); - }); - - it("when set to 'mixed'", () => { - expect( - getAccessibilityProp( - , - 'accessibilityState', - ).includes('checked:Mixed'), - ).toEqual(true); - }); - }); - - it('take precedence over the matching `accessibilityState` field', () => { - const accessibilityState = getAccessibilityProp( - , - 'accessibilityState', - ); - - expect(accessibilityState.includes('busy:true')).toEqual(true); - // Fields not covered by an `aria-*` prop are preserved. - expect(accessibilityState.includes('disabled:true')).toEqual(true); - }); - }); }); describe('accessibilityActions', () => { From 6b4b252c78d144ef9647189e37b2271088cd3965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 30 Jul 2026 14:07:31 +0200 Subject: [PATCH 5/5] tweak: EMPTY_IMAGE_SOURCE --- .../react-native/Libraries/Image/Image.ios.js | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/react-native/Libraries/Image/Image.ios.js b/packages/react-native/Libraries/Image/Image.ios.js index 5998f5ba4c9a..da0735c8fa59 100644 --- a/packages/react-native/Libraries/Image/Image.ios.js +++ b/packages/react-native/Libraries/Image/Image.ios.js @@ -103,6 +103,12 @@ async function queryCache( return NativeImageLoaderIOS.queryCache(urls); } +const EMPTY_IMAGE_SOURCE = { + uri: undefined, + width: undefined, + height: undefined, +}; + /** * A React component for displaying different types of images, * including network images, static resources, temporary local images, and @@ -139,19 +145,16 @@ let BaseImage: AbstractImageIOS = ({ ref?: React.RefSetter, ...ImageProps, }) => { - const resolvedSource = getImageSourcesFromImageProps({ - crossOrigin, - height, - referrerPolicy, - source, - src, - srcSet, - width, - }) || { - uri: undefined, - width: undefined, - height: undefined, - }; + const resolvedSource = + getImageSourcesFromImageProps({ + crossOrigin, + height, + referrerPolicy, + source, + src, + srcSet, + width, + }) || EMPTY_IMAGE_SOURCE; let resolvedStyle: ImageStyleProp; let sources;