Skip to content

Commit

Permalink
Add experimental global callback for attached images (#41525)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #41525

This creates an experimental mechanism to get notifications when image instances are created anywhere in the app.

This can be useful to set up things like image performance tracking automatically without having to use a custom component and manually access refs from image components.

Changelog: [internal]

Reviewed By: oprisnik

Differential Revision: D49962063

fbshipit-source-id: b991a808aaa723bea98c27812892cfa468f025a6
  • Loading branch information
rubennorte authored and facebook-github-bot committed Nov 17, 2023
1 parent 02bb50d commit 5e7be7e
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 6 deletions.
11 changes: 8 additions & 3 deletions packages/react-native/Libraries/Image/Image.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import flattenStyle from '../StyleSheet/flattenStyle';
import StyleSheet from '../StyleSheet/StyleSheet';
import TextAncestor from '../Text/TextAncestor';
import ImageAnalyticsTagContext from './ImageAnalyticsTagContext';
import {unstable_getImageComponentDecorator} from './ImageInjection';
import {
unstable_getImageComponentDecorator,
useWrapRefWithImageAttachedCallbacks,
} from './ImageInjection';
import {getImageSourcesFromImageProps} from './ImageSourceUtils';
import {convertObjectFitToResizeMode} from './ImageUtils';
import ImageViewNativeComponent from './ImageViewNativeComponent';
Expand Down Expand Up @@ -176,7 +179,6 @@ let BaseImage: AbstractImageAndroid = React.forwardRef(
loadingIndicatorSrc: loadingIndicatorSource
? loadingIndicatorSource.uri
: null,
ref: forwardedRef,
accessibilityLabel:
props['aria-label'] ?? props.accessibilityLabel ?? props.alt,
accessibilityLabelledBy:
Expand All @@ -197,6 +199,8 @@ let BaseImage: AbstractImageAndroid = React.forwardRef(
const resizeMode =
objectFit || props.resizeMode || style?.resizeMode || 'cover';

const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef);

return (
<ImageAnalyticsTagContext.Consumer>
{analyticTag => {
Expand All @@ -218,7 +222,7 @@ let BaseImage: AbstractImageAndroid = React.forwardRef(
resizeMode={resizeMode}
headers={nativeProps.headers}
src={sources}
ref={forwardedRef}
ref={actualRef}
/>
);
}
Expand All @@ -227,6 +231,7 @@ let BaseImage: AbstractImageAndroid = React.forwardRef(
<ImageViewNativeComponent
{...nativePropsWithAnalytics}
resizeMode={resizeMode}
ref={actualRef}
/>
);
}}
Expand Down
9 changes: 7 additions & 2 deletions packages/react-native/Libraries/Image/Image.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {createRootTag} from '../ReactNative/RootTag';
import flattenStyle from '../StyleSheet/flattenStyle';
import StyleSheet from '../StyleSheet/StyleSheet';
import ImageAnalyticsTagContext from './ImageAnalyticsTagContext';
import {unstable_getImageComponentDecorator} from './ImageInjection';
import {
unstable_getImageComponentDecorator,
useWrapRefWithImageAttachedCallbacks,
} from './ImageInjection';
import {getImageSourcesFromImageProps} from './ImageSourceUtils';
import {convertObjectFitToResizeMode} from './ImageUtils';
import ImageViewNativeComponent from './ImageViewNativeComponent';
Expand Down Expand Up @@ -158,6 +161,8 @@ let BaseImage: AbstractImageIOS = React.forwardRef((props, forwardedRef) => {
};
const accessibilityLabel = props['aria-label'] ?? props.accessibilityLabel;

const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef);

return (
<ImageAnalyticsTagContext.Consumer>
{analyticTag => {
Expand All @@ -167,7 +172,7 @@ let BaseImage: AbstractImageIOS = React.forwardRef((props, forwardedRef) => {
{...restProps}
accessible={props.alt !== undefined ? true : props.accessible}
accessibilityLabel={accessibilityLabel ?? props.alt}
ref={forwardedRef}
ref={actualRef}
style={style}
resizeMode={resizeMode}
tintColor={tintColor}
Expand Down
60 changes: 59 additions & 1 deletion packages/react-native/Libraries/Image/ImageInjection.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
* @flow strict-local
*/

import type {AbstractImageAndroid, AbstractImageIOS} from './ImageTypes.flow';
import type {
AbstractImageAndroid,
AbstractImageIOS,
Image as ImageComponent,
} from './ImageTypes.flow';

import * as React from 'react';
import {useRef} from 'react';

type ImageComponentDecorator = (AbstractImageAndroid => AbstractImageAndroid) &
(AbstractImageIOS => AbstractImageIOS);
Expand All @@ -24,3 +31,54 @@ export function unstable_setImageComponentDecorator(
export function unstable_getImageComponentDecorator(): ?ImageComponentDecorator {
return injectedImageComponentDecorator;
}

type ImageInstance = React.ElementRef<ImageComponent>;

type ImageAttachedCallback = (
imageInstance: ImageInstance,
) => void | (() => void);

const imageAttachedCallbacks = new Set<ImageAttachedCallback>();

export function unstable_registerImageAttachedCallback(
callback: ImageAttachedCallback,
): void {
imageAttachedCallbacks.add(callback);
}

export function unstable_unregisterImageAttachedCallback(
callback: ImageAttachedCallback,
): void {
imageAttachedCallbacks.delete(callback);
}

type ProxyRef = (ImageInstance | null) => void;

export function useWrapRefWithImageAttachedCallbacks(
forwardedRef?: React.Ref<ImageComponent>,
): ProxyRef {
const pendingCleanupCallbacks = useRef<Array<() => void>>([]);
const proxyRef = useRef<ProxyRef>(node => {
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (typeof forwardedRef === 'object' && forwardedRef != null) {
forwardedRef.current = node;
}

if (node == null) {
if (pendingCleanupCallbacks.current.length > 0) {
pendingCleanupCallbacks.current.forEach(cb => cb());
pendingCleanupCallbacks.current = [];
}
} else {
imageAttachedCallbacks.forEach(imageAttachedCallback => {
const maybeCleanupCallback = imageAttachedCallback(node);
if (maybeCleanupCallback != null) {
pendingCleanupCallbacks.current.push(maybeCleanupCallback);
}
});
}
});

return proxyRef.current;
}
191 changes: 191 additions & 0 deletions packages/react-native/Libraries/Image/__tests__/Image-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@

'use strict';

import type {ElementRef} from 'react';

import {act, create} from 'react-test-renderer';

const render = require('../../../jest/renderer');
const Image = require('../Image');
const ImageInjection = require('../ImageInjection');
const React = require('react');

describe('<Image />', () => {
Expand All @@ -39,4 +44,190 @@ describe('<Image />', () => {
const instance = render.create(<Image source={{uri: 'foo-bar.jpg'}} />);
expect(instance).toMatchSnapshot();
});

it('should call image attached callbacks (basic)', () => {
jest.dontMock('../Image');

let imageInstanceFromCallback = null;
let imageInstanceFromRef = null;

const callback = (instance: ElementRef<typeof Image>) => {
imageInstanceFromCallback = instance;

return () => {
imageInstanceFromCallback = null;
};
};

ImageInjection.unstable_registerImageAttachedCallback(callback);

expect(imageInstanceFromCallback).toBe(null);

let testRenderer;

act(() => {
testRenderer = create(
<Image
source={{uri: 'foo-bar.jpg'}}
ref={instance => {
imageInstanceFromRef = instance;
}}
/>,
);
});

expect(imageInstanceFromCallback).not.toBe(null);
expect(imageInstanceFromRef).not.toBe(null);
expect(imageInstanceFromCallback).toBe(imageInstanceFromRef);

act(() => {
testRenderer.update(<></>);
});

expect(imageInstanceFromCallback).toBe(null);
expect(imageInstanceFromRef).toBe(null);

ImageInjection.unstable_unregisterImageAttachedCallback(callback);

act(() => {
testRenderer.update(
<Image
source={{uri: 'foo-bar.jpg'}}
ref={instance => {
imageInstanceFromRef = instance;
}}
/>,
);
});

expect(imageInstanceFromRef).not.toBe(null);
expect(imageInstanceFromCallback).toBe(null);
});

it('should call image attached callbacks (multiple callbacks)', () => {
jest.dontMock('../Image');

let imageInstanceFromCallback1 = null;
let imageInstanceFromCallback2 = null;
let imageInstanceFromRef = null;

ImageInjection.unstable_registerImageAttachedCallback(instance => {
imageInstanceFromCallback1 = instance;

return () => {
imageInstanceFromCallback1 = null;
};
});

ImageInjection.unstable_registerImageAttachedCallback(instance => {
imageInstanceFromCallback2 = instance;

return () => {
imageInstanceFromCallback2 = null;
};
});

expect(imageInstanceFromCallback1).toBe(null);
expect(imageInstanceFromCallback2).toBe(null);

let testRenderer;

act(() => {
testRenderer = create(
<Image
source={{uri: 'foo-bar.jpg'}}
ref={instance => {
imageInstanceFromRef = instance;
}}
/>,
);
});

expect(imageInstanceFromRef).not.toBe(null);
expect(imageInstanceFromCallback1).not.toBe(null);
expect(imageInstanceFromCallback2).not.toBe(null);
expect(imageInstanceFromCallback1).toBe(imageInstanceFromRef);
expect(imageInstanceFromCallback2).toBe(imageInstanceFromRef);

act(() => {
testRenderer.update(<></>);
});

expect(imageInstanceFromRef).toBe(null);
expect(imageInstanceFromCallback1).toBe(null);
expect(imageInstanceFromCallback2).toBe(null);
});

it('should call image attached callbacks (multiple images)', () => {
jest.dontMock('../Image');

let imageInstancesFromCallback = new Set<ElementRef<typeof Image>>();

ImageInjection.unstable_registerImageAttachedCallback(instance => {
imageInstancesFromCallback.add(instance);

return () => {
imageInstancesFromCallback.delete(instance);
};
});

expect(imageInstancesFromCallback.size).toBe(0);

let testRenderer;

let firstInstance;
let secondInstance;

const firstImageElement = (
<Image
key="first-image"
source={{uri: 'foo-bar.jpg'}}
ref={instance => {
firstInstance = instance;
}}
/>
);

const secondImageElement = (
<Image
key="second-image"
source={{uri: 'foo-bar-baz.jpg'}}
ref={instance => {
secondInstance = instance;
}}
/>
);

act(() => {
testRenderer = create(
<>
{firstImageElement}
{secondImageElement}
</>,
);
});

expect(firstInstance).not.toBe(null);
expect(secondInstance).not.toBe(null);
expect(imageInstancesFromCallback.size).toBe(2);
expect([...imageInstancesFromCallback][0]).toBe(firstInstance);
expect([...imageInstancesFromCallback][1]).toBe(secondInstance);

act(() => {
testRenderer.update(<>{secondImageElement}</>);
});

expect(firstInstance).toBe(null);
expect(secondInstance).not.toBe(null);
expect(imageInstancesFromCallback.size).toBe(1);
expect([...imageInstancesFromCallback][0]).toBe(secondInstance);

act(() => {
testRenderer.update(<></>);
});

expect(firstInstance).toBe(null);
expect(secondInstance).toBe(null);
expect(imageInstancesFromCallback.size).toBe(0);
});
});

0 comments on commit 5e7be7e

Please sign in to comment.