Skip to content

Commit c3caea9

Browse files
mdjastrzebskimeta-codesync[bot]
authored andcommitted
fix(ios): extract Image.ios.js props once (#57710)
Summary: Two changes to `Image.ios.js` (follow up to #57633): 1. **Extract props once.** Per javache's review comment, props are destructured once in the parameter list and written into a single `resolvedProps` object, spread into `ImageViewNativeComponent` once — modelled on `View`, matching `Image.android.js`. `ImageAnalyticsTagContext.Consumer` is replaced with `use(ImageAnalyticsTagContext)`. 2. **Align `aria-*` vs `accessibilityState`.** `aria-*` state props now take priority per key with fallback to `accessibilityState`, matching `View`, `Text`, `TextInput`, `Pressable` and `Image.android.js`. `Image.ios.js` was the only outlier, where the raw `accessibilityState` object replaced the merged one. Also aligns the `alt` null-check with `Image.android.js` (`alt != null`), so an explicit `alt={null}` no longer forces `accessible: true`. Adds `Image-itest.js` coverage for the `aria-*` to `accessibilityState` mapping, including `aria-checked` as `true`/`false`/`"mixed"`, precedence over the matching `accessibilityState` field, and that `accessibilityState` is not emitted when no state props are given. ## Changelog: <!-- Help reviewers and the release process by writing your own changelog entry. Pick one each for the category and type tags: [ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message For more details, see: https://reactnative.dev/contributing/changelogs-in-pull-requests --> [IOS] [FIXED] - Image: aria-* state props are no longer ignored when accessibilityState is also present Pull Request resolved: #57710 Test Plan: Unchanged tests pass + added new tests Reviewed By: christophpurrer Differential Revision: D114048967 Pulled By: javache fbshipit-source-id: c09647dba932cb747ecab6392e281009e85b8d3f
1 parent 3b5d0a6 commit c3caea9

3 files changed

Lines changed: 198 additions & 80 deletions

File tree

packages/react-native/Libraries/Image/Image.android.js

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ const EMPTY_IMAGE_SOURCE = {
139139
let BaseImage: AbstractImageAndroid = ({
140140
ref: forwardedRef,
141141
alt,
142-
accessible,
143142
'aria-labelledby': ariaLabelledBy,
144143
'aria-busy': ariaBusy,
145144
'aria-checked': ariaChecked,
@@ -148,8 +147,6 @@ let BaseImage: AbstractImageAndroid = ({
148147
'aria-hidden': ariaHidden,
149148
'aria-label': ariaLabel,
150149
'aria-selected': ariaSelected,
151-
accessibilityLabel,
152-
accessibilityLabelledBy,
153150
accessibilityState,
154151
defaultSource,
155152
loadingIndicatorSource,
@@ -172,7 +169,7 @@ let BaseImage: AbstractImageAndroid = ({
172169
ref?: React.RefSetter<ImageInstance>,
173170
...ImageProps,
174171
}) => {
175-
let source_ =
172+
let resolvedSource =
176173
getImageSourcesFromImageProps({
177174
crossOrigin,
178175
referrerPolicy,
@@ -201,29 +198,29 @@ let BaseImage: AbstractImageAndroid = ({
201198
...React.PropsOf<ImageViewNativeComponent>,
202199
};
203200

204-
if (Array.isArray(source_)) {
201+
if (Array.isArray(resolvedSource)) {
205202
const {
206203
headers: sourceHeaders,
207204
width: sourceWidth,
208205
height: sourceHeight,
209-
} = source_[0];
206+
} = resolvedSource[0];
210207
if (sourceHeaders != null) {
211208
nativeProps.headers = sourceHeaders;
212209
}
213210
// Default to the first source's width and height if only one is provided
214211
nativeProps.style = [
215-
source_.length === 1 && {width: sourceWidth, height: sourceHeight},
212+
resolvedSource.length === 1 && {width: sourceWidth, height: sourceHeight},
216213
styles.base,
217214
style,
218215
];
219-
nativeProps.source = source_;
216+
nativeProps.source = resolvedSource;
220217
} else {
221218
const {
222219
uri,
223220
width: sourceWidth,
224221
height: sourceHeight,
225222
headers: sourceHeaders,
226-
} = source_;
223+
} = resolvedSource;
227224
if (uri === '') {
228225
console.warn('source.uri should not be an empty string');
229226
}
@@ -235,7 +232,7 @@ let BaseImage: AbstractImageAndroid = ({
235232
styles.base,
236233
style,
237234
];
238-
nativeProps.source = [source_];
235+
nativeProps.source = [resolvedSource];
239236
}
240237

241238
if (onLoadStart != null) {
@@ -266,24 +263,19 @@ let BaseImage: AbstractImageAndroid = ({
266263
nativeProps.loadingIndicatorSrc = loadingIndicatorSource_.uri;
267264
}
268265

266+
// Maintain pre-existing order, accessibilityLabel takes priority over alt
269267
if (ariaLabel != null) {
270268
nativeProps.accessibilityLabel = ariaLabel;
271-
} else if (accessibilityLabel != null) {
272-
nativeProps.accessibilityLabel = accessibilityLabel;
273-
} else if (alt != null) {
269+
} else if (alt != null && nativeProps.accessibilityLabel == null) {
274270
nativeProps.accessibilityLabel = alt;
275271
}
276272

277273
if (ariaLabelledBy != null) {
278274
nativeProps.accessibilityLabelledBy = ariaLabelledBy;
279-
} else if (accessibilityLabelledBy != null) {
280-
nativeProps.accessibilityLabelledBy = accessibilityLabelledBy;
281275
}
282276

283277
if (alt != null) {
284278
nativeProps.accessible = true;
285-
} else if (accessible != null) {
286-
nativeProps.accessible = accessible;
287279
}
288280

289281
if (

packages/react-native/Libraries/Image/Image.ios.js

Lines changed: 107 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import ImageViewNativeComponent from './ImageViewNativeComponent';
2929
import NativeImageLoaderIOS from './NativeImageLoaderIOS';
3030
import resolveAssetSource from './resolveAssetSource';
3131
import * as React from 'react';
32+
import {use} from 'react';
3233

3334
export type ImageInstance = HostInstance;
3435

@@ -102,6 +103,12 @@ async function queryCache(
102103
return NativeImageLoaderIOS.queryCache(urls);
103104
}
104105

106+
const EMPTY_IMAGE_SOURCE = {
107+
uri: undefined,
108+
width: undefined,
109+
height: undefined,
110+
};
111+
105112
/**
106113
* A React component for displaying different types of images,
107114
* including network images, static resources, temporary local images, and
@@ -111,90 +118,127 @@ async function queryCache(
111118
*/
112119
let BaseImage: AbstractImageIOS = ({
113120
ref: forwardedRef,
114-
...props
121+
'aria-labelledby': ariaLabelledBy,
122+
'aria-busy': ariaBusy,
123+
'aria-checked': ariaChecked,
124+
'aria-disabled': ariaDisabled,
125+
'aria-expanded': ariaExpanded,
126+
'aria-hidden': ariaHidden,
127+
'aria-label': ariaLabel,
128+
'aria-selected': ariaSelected,
129+
accessibilityState,
130+
alt,
131+
children,
132+
crossOrigin,
133+
height,
134+
referrerPolicy,
135+
resizeMode,
136+
source,
137+
src,
138+
srcSet,
139+
style,
140+
tintColor,
141+
width,
142+
...restProps
115143
}: {
116144
ref?: React.RefSetter<ImageInstance>,
117145
...ImageProps,
118146
}) => {
119-
const source = getImageSourcesFromImageProps(props) || {
120-
uri: undefined,
121-
width: undefined,
122-
height: undefined,
147+
const nativeProps = restProps as {
148+
...React.PropsOf<ImageViewNativeComponent>,
123149
};
124150

125-
let style: ImageStyleProp;
151+
const resolvedSource =
152+
getImageSourcesFromImageProps({
153+
crossOrigin,
154+
height,
155+
referrerPolicy,
156+
source,
157+
src,
158+
srcSet,
159+
width,
160+
}) || EMPTY_IMAGE_SOURCE;
161+
162+
let resolvedStyle: ImageStyleProp;
126163
let sources;
127-
if (Array.isArray(source)) {
128-
style = [styles.base, props.style];
129-
sources = source;
164+
if (Array.isArray(resolvedSource)) {
165+
resolvedStyle = [styles.base, style];
166+
sources = resolvedSource;
130167
} else {
131-
const {uri} = source;
168+
const {uri} = resolvedSource;
132169
if (uri === '') {
133170
console.warn('source.uri should not be an empty string');
134171
}
135-
const width = source.width ?? props.width;
136-
const height = source.height ?? props.height;
137-
style = [{width, height}, styles.base, props.style];
138-
sources = [source];
172+
resolvedStyle = [
173+
{
174+
width: resolvedSource.width ?? width,
175+
height: resolvedSource.height ?? height,
176+
},
177+
styles.base,
178+
style,
179+
];
180+
sources = [resolvedSource];
139181
}
140182

141-
const flattenedStyle = flattenStyle<ImageStyleProp>(style);
142-
const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit);
143-
const resizeMode =
144-
objectFit || props.resizeMode || flattenedStyle?.resizeMode || 'cover';
145-
const tintColor = props.tintColor ?? flattenedStyle?.tintColor;
146-
147-
if (props.children != null) {
183+
if (children != null) {
148184
throw new Error(
149185
'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.',
150186
);
151187
}
152-
const {
153-
'aria-busy': ariaBusy,
154-
'aria-checked': ariaChecked,
155-
'aria-disabled': ariaDisabled,
156-
'aria-expanded': ariaExpanded,
157-
'aria-selected': ariaSelected,
158-
'aria-hidden': ariaHidden,
159-
src,
160-
...restProps
161-
} = props;
162-
163-
const _accessibilityState = {
164-
busy: ariaBusy ?? props.accessibilityState?.busy,
165-
checked: ariaChecked ?? props.accessibilityState?.checked,
166-
disabled: ariaDisabled ?? props.accessibilityState?.disabled,
167-
expanded: ariaExpanded ?? props.accessibilityState?.expanded,
168-
selected: ariaSelected ?? props.accessibilityState?.selected,
169-
};
170188

171-
// In order for `aria-hidden` to work on iOS we must set `accessible` to false (`accessibilityElementsHidden` is not enough).
172-
const accessible =
173-
ariaHidden !== true && (props.alt !== undefined ? true : props.accessible);
174-
const accessibilityLabel = props['aria-label'] ?? props.accessibilityLabel;
189+
nativeProps.style = resolvedStyle;
190+
nativeProps.source = sources;
191+
192+
const flattenedStyle = flattenStyle<ImageStyleProp>(resolvedStyle);
193+
const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit);
194+
nativeProps.resizeMode =
195+
objectFit || resizeMode || flattenedStyle?.resizeMode || 'cover';
196+
nativeProps.tintColor = tintColor ?? flattenedStyle?.tintColor;
197+
198+
// Maintain pre-existing order, accessibilityLabel takes priority over alt
199+
if (ariaLabel != null) {
200+
nativeProps.accessibilityLabel = ariaLabel;
201+
} else if (alt != null && nativeProps.accessibilityLabel == null) {
202+
nativeProps.accessibilityLabel = alt;
203+
}
204+
205+
if (ariaLabelledBy != null) {
206+
nativeProps.accessibilityLabelledBy = ariaLabelledBy;
207+
}
208+
209+
if (ariaHidden === true) {
210+
// In order for `aria-hidden` to work on iOS we must set `accessible` to
211+
// false (`accessibilityElementsHidden` is not enough).
212+
nativeProps.accessible = false;
213+
} else if (alt != null) {
214+
nativeProps.accessible = true;
215+
}
216+
217+
if (
218+
accessibilityState != null ||
219+
ariaBusy != null ||
220+
ariaChecked != null ||
221+
ariaDisabled != null ||
222+
ariaExpanded != null ||
223+
ariaSelected != null
224+
) {
225+
nativeProps.accessibilityState = {
226+
busy: ariaBusy ?? accessibilityState?.busy,
227+
checked: ariaChecked ?? accessibilityState?.checked,
228+
disabled: ariaDisabled ?? accessibilityState?.disabled,
229+
expanded: ariaExpanded ?? accessibilityState?.expanded,
230+
selected: ariaSelected ?? accessibilityState?.selected,
231+
};
232+
}
175233

176234
const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef);
177235

178-
return (
179-
<ImageAnalyticsTagContext.Consumer>
180-
{analyticTag => {
181-
return (
182-
<ImageViewNativeComponent
183-
accessibilityState={_accessibilityState}
184-
{...restProps}
185-
accessible={accessible}
186-
accessibilityLabel={accessibilityLabel ?? props.alt}
187-
ref={actualRef}
188-
style={style}
189-
resizeMode={resizeMode}
190-
tintColor={tintColor}
191-
source={sources}
192-
internal_analyticTag={analyticTag}
193-
/>
194-
);
195-
}}
196-
</ImageAnalyticsTagContext.Consumer>
197-
);
236+
const analyticTag = use(ImageAnalyticsTagContext);
237+
if (analyticTag != null) {
238+
nativeProps.internal_analyticTag = analyticTag;
239+
}
240+
241+
return <ImageViewNativeComponent {...nativeProps} ref={actualRef} />;
198242
};
199243

200244
const imageComponentDecorator = unstable_getImageComponentDecorator();

packages/react-native/Libraries/Image/__tests__/Image-itest.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,88 @@ describe('<Image>', () => {
648648
});
649649
});
650650

651+
describe('accessibilityState', () => {
652+
function getAccessibilityState(element: React.MixedElement) {
653+
const root = Fantom.createRoot();
654+
655+
Fantom.runTask(() => {
656+
root.render(element);
657+
});
658+
659+
return root
660+
.getRenderedOutput({props: ['accessibilityState']})
661+
.toJSONObject().props.accessibilityState;
662+
}
663+
664+
it('is not set when no state props are provided', () => {
665+
const root = Fantom.createRoot();
666+
667+
Fantom.runTask(() => {
668+
root.render(<Image />);
669+
});
670+
671+
expect(
672+
root.getRenderedOutput({props: ['accessibilityState']}).toJSX(),
673+
).toEqual(<rn-image />);
674+
});
675+
676+
it('maps \'aria-busy\' to "busy"', () => {
677+
expect(getAccessibilityState(<Image aria-busy={true} />)).toContain(
678+
'busy:true',
679+
);
680+
});
681+
682+
it('maps \'aria-disabled\' to "disabled"', () => {
683+
expect(getAccessibilityState(<Image aria-disabled={true} />)).toContain(
684+
'disabled:true',
685+
);
686+
});
687+
688+
it('maps \'aria-expanded\' to "expanded"', () => {
689+
expect(getAccessibilityState(<Image aria-expanded={true} />)).toContain(
690+
'expanded:true',
691+
);
692+
});
693+
694+
it('maps \'aria-selected\' to "selected"', () => {
695+
expect(getAccessibilityState(<Image aria-selected={true} />)).toContain(
696+
'selected:true',
697+
);
698+
});
699+
700+
describe('maps \'aria-checked\' to "checked"', () => {
701+
it('when set to true', () => {
702+
expect(
703+
getAccessibilityState(<Image aria-checked={true} />),
704+
).toContain('checked:Checked');
705+
});
706+
707+
it('when set to false', () => {
708+
expect(
709+
getAccessibilityState(<Image aria-checked={false} />),
710+
).toContain('checked:Unchecked');
711+
});
712+
713+
it("when set to 'mixed'", () => {
714+
expect(
715+
getAccessibilityState(<Image aria-checked="mixed" />),
716+
).toContain('checked:Mixed');
717+
});
718+
});
719+
720+
it('gives `aria-*` precedence over the matching field', () => {
721+
const accessibilityState = getAccessibilityState(
722+
<Image
723+
accessibilityState={{busy: false, disabled: true}}
724+
aria-busy={true}
725+
/>,
726+
);
727+
728+
expect(accessibilityState).toContain('busy:true');
729+
expect(accessibilityState).toContain('disabled:true');
730+
});
731+
});
732+
651733
component TestComponent(testID?: ?string, ...props: AccessibilityProps) {
652734
return <Image {...props} testID={testID} source={LOGO_SOURCE} />;
653735
}

0 commit comments

Comments
 (0)