From 399eb546cfc837502cd5ff1f9b73502284c3d3dd Mon Sep 17 00:00:00 2001 From: "Jules Sam. Randolph" Date: Wed, 10 Feb 2021 11:17:48 -0300 Subject: [PATCH] feat: reuse 'img' renderer internal logic w/t `useIMGElementState` hook This is a complete refactoring as much as a feature of the image renderer. Thanks to this new hook, you can very easily create custom image renderers which still preserves the rich scaling behavior of the internal component. This hook will return three different states: - 'loading', when the natural image dimensions have not been retrieved yet; - 'success', when the concrete image dimensions are available; - 'error', when the image could not be cached. During the 'loading' phase, this hooks uses `Image.getSize` from react native to pre-fetch the image and access its natural (intrinsic) dimensions. See https://drafts.csswg.org/css-images/#sizing-terms for detailed definitions relating to "concrete", "specified" and "natural" dimensions. fix #424 --- packages/render-html/package.json | 2 + .../render-html/src/__mocks__/react-native.js | 2 +- .../render-html/src/elements/IMGElement.tsx | 584 ++---------------- .../src/elements/IMGElementContainer.tsx | 32 + .../src/elements/IMGElementContentError.tsx | 29 + .../src/elements/IMGElementContentLoading.tsx | 9 + .../src/elements/IMGElementContentSuccess.tsx | 19 + ...ent.image.test.tsx => IMGElement.test.tsx} | 0 .../__tests__/useIMGElementLoader.test.ts | 52 ++ .../render-html/src/elements/img-types.ts | 77 +++ .../src/elements/useIMGElementLoader.tsx | 374 +++++++++++ packages/render-html/src/index.ts | 9 + yarn.lock | 235 ++++++- 13 files changed, 883 insertions(+), 541 deletions(-) create mode 100644 packages/render-html/src/elements/IMGElementContainer.tsx create mode 100644 packages/render-html/src/elements/IMGElementContentError.tsx create mode 100644 packages/render-html/src/elements/IMGElementContentLoading.tsx create mode 100644 packages/render-html/src/elements/IMGElementContentSuccess.tsx rename packages/render-html/src/elements/__tests__/{element.image.test.tsx => IMGElement.test.tsx} (100%) create mode 100644 packages/render-html/src/elements/__tests__/useIMGElementLoader.test.ts create mode 100644 packages/render-html/src/elements/img-types.ts create mode 100644 packages/render-html/src/elements/useIMGElementLoader.tsx diff --git a/packages/render-html/package.json b/packages/render-html/package.json index a57ba4894..dae08de97 100644 --- a/packages/render-html/package.json +++ b/packages/render-html/package.json @@ -39,6 +39,7 @@ "@babel/preset-typescript": "^7.12.13", "@babel/runtime": "^7.12.13", "@release-it/conventional-changelog": "^2.0.0", + "@testing-library/react-hooks": "^5.0.3", "@types/jest": "^26.0.14", "@types/react-native": "^0.63.35", "@types/react-test-renderer": "^17.0.0", @@ -51,6 +52,7 @@ "react-native": "^0.63.4", "react-native-builder-bob": "^0.17.1", "react-native-testing-library": "^6.0.0", + "react-performance-testing": "^1.2.3", "react-test-renderer": "^17.0.1", "release-it": "^14.2.1", "typescript": "~3.9.7" diff --git a/packages/render-html/src/__mocks__/react-native.js b/packages/render-html/src/__mocks__/react-native.js index f20477b2a..996a71143 100644 --- a/packages/render-html/src/__mocks__/react-native.js +++ b/packages/render-html/src/__mocks__/react-native.js @@ -24,6 +24,6 @@ export class Image extends RNImage { } } callback.apply(null, dimensions); - }, 0); + }, 50); } } diff --git a/packages/render-html/src/elements/IMGElement.tsx b/packages/render-html/src/elements/IMGElement.tsx index bbe34c9f4..5442e061f 100644 --- a/packages/render-html/src/elements/IMGElement.tsx +++ b/packages/render-html/src/elements/IMGElement.tsx @@ -1,540 +1,64 @@ -import React, { ComponentClass, PureComponent } from 'react'; -import { - Image, - View, - Text, - StyleSheet, - ImageStyle, - PressableProps, - StyleProp, - ImageURISource -} from 'react-native'; +import React, { ReactNode } from 'react'; import PropTypes from 'prop-types'; -import GenericPressable from '../GenericPressable'; -import { ImageDimensions } from '../shared-types'; -import pick from 'ramda/src/pick'; +import useIMGElementLoader from './useIMGElementLoader'; +import IMGElementContentSuccess from './IMGElementContentSuccess'; +import IMGElementContainer from './IMGElementContainer'; +import IMGElementContentLoading from './IMGElementContentLoading'; +import IMGElementContentError from './IMGElementContentError'; +import { IMGElementProps } from './img-types'; -export interface ImgDimensions { - width: number; - height: number; -} - -export interface IncompleteImgDimensions { - width: number | null; - height: number | null; -} - -export interface IMGElementProps { - source: ImageURISource; - alt?: string; - height?: string | number; - width?: string | number; - style?: StyleProp; - testID?: string; - computeImagesMaxWidth?: (containerWidth: number) => number; - onPress?: PressableProps['onPress']; - altColor?: string; - contentWidth?: number; - enableExperimentalPercentWidth?: boolean; - imagesInitialDimensions?: ImgDimensions; -} - -const defaultImageStyle: ImageStyle = { resizeMode: 'cover' }; -const emptyObject = {}; - -const styles = StyleSheet.create({ - image: { resizeMode: 'cover' }, - errorBox: { - borderWidth: 1, - borderColor: 'lightgray', - overflow: 'hidden', - justifyContent: 'center' - }, - errorText: { textAlign: 'center', fontStyle: 'italic' }, - container: { - flexDirection: 'row', - alignSelf: 'stretch', - justifyContent: 'center' - } -}); - -const extractImgStyleProps = pick([ - 'resizeMode', - 'tintColor', - 'overlayColor' -]); - -function attemptParseFloat(value: any) { - const result = parseFloat(value); - return Number.isNaN(result) ? null : result; -} - -function normalizeSize( - dimension: string | number | null | undefined, - options: Partial<{ - containerDimension: number | null; - enablePercentWidth: boolean; - }> = {} -) { - const containerDimension = options.containerDimension || null; - const enablePercentWidth = options.enablePercentWidth || false; - if ( - dimension === null || - dimension === undefined || - Number.isNaN(dimension) - ) { - return null; - } - if (typeof dimension === 'number') { - return dimension; - } - if (typeof dimension === 'string') { - if ( - dimension.search('%') !== -1 && - enablePercentWidth && - typeof containerDimension === 'number' - ) { - const parsedFloat = attemptParseFloat(dimension); - if (parsedFloat === null || Number.isNaN(parsedFloat)) { - return null; - } - return (parsedFloat * containerDimension) / 100; - } else if (dimension.trim().match(/^[\d.]+$/)) { - return attemptParseFloat(dimension); - } - } - return null; -} - -function extractHorizontalSpace({ - marginHorizontal, - leftMargin, - rightMargin, - margin -}: any = {}) { - const realLeftMargin = leftMargin || marginHorizontal || margin || 0; - const realRightMargin = rightMargin || marginHorizontal || margin || 0; - return realLeftMargin + realRightMargin; -} - -function derivePhysicalDimensionsFromProps({ - width, - height, - contentWidth, - enableExperimentalPercentWidth: enablePercentWidth -}: IMGElementProps): IncompleteImgDimensions { - const normalizeOptionsWidth = { - enablePercentWidth, - containerDimension: contentWidth - }; - const normalizeOptionsHeight = { - enablePercentWidth: false - }; - const widthProp = normalizeSize(width, normalizeOptionsWidth); - const heightProp = normalizeSize(height, normalizeOptionsHeight); - return { - width: widthProp, - height: heightProp - }; -} - -function deriveRequiredDimensionsFromProps({ - enablePercentWidth, - contentWidth, - flatStyle, - physicalDimensionsFromProps -}: Pick & { - flatStyle: Record; - enablePercentWidth?: boolean; - physicalDimensionsFromProps: IncompleteImgDimensions; -}): IncompleteImgDimensions { - const normalizeOptionsWidth = { - enablePercentWidth, - containerDimension: contentWidth - }; - const normalizeOptionsHeight = { - enablePercentWidth: false - }; - const styleWidth = normalizeSize(flatStyle.width, normalizeOptionsWidth); - const styleHeight = normalizeSize(flatStyle.height, normalizeOptionsHeight); - return { - width: - typeof styleWidth === 'number' - ? styleWidth - : physicalDimensionsFromProps.width, - height: - typeof styleHeight === 'number' - ? styleHeight - : physicalDimensionsFromProps.height - }; -} - -function scaleUp( - minDimensions: ImgDimensions, - desiredDimensions: ImgDimensions -): ImgDimensions { - const aspectRatio = desiredDimensions.width / desiredDimensions.height; - if (desiredDimensions.width < minDimensions.width) { - return scaleUp(minDimensions, { - width: minDimensions.width, - height: minDimensions.width / aspectRatio - }); - } - if (desiredDimensions.height < minDimensions.height) { - return scaleUp(minDimensions, { - height: minDimensions.height, - width: minDimensions.height * aspectRatio - }); - } - return desiredDimensions; -} - -function scaleDown( - maxDimensions: ImgDimensions, - desiredDimensions: ImgDimensions -): ImgDimensions { - const aspectRatio = desiredDimensions.width / desiredDimensions.height; - if (desiredDimensions.width > maxDimensions.width) { - return scaleDown(maxDimensions, { - width: maxDimensions.width, - height: maxDimensions.width / aspectRatio - }); - } - if (desiredDimensions.height > maxDimensions.height) { - return scaleDown(maxDimensions, { - height: maxDimensions.height, - width: maxDimensions.height * aspectRatio - }); - } - return desiredDimensions; -} - -function scale( - { minBox, maxBox }: { minBox: ImgDimensions; maxBox: ImgDimensions }, - originalBox: ImgDimensions -) { - return scaleDown(maxBox, scaleUp(minBox, originalBox)); -} - -function sourcesAreEqual(source1: any, source2: any) { - return ( - (source1 && source2 && source1.uri === source2.uri) || source1 === source2 - ); -} +export type { IMGElementProps } from './img-types'; function identity(arg: any) { return arg; } -function computeImageBoxDimensions(params: any) { - const { - computeImagesMaxWidth, - contentWidth, - flattenStyles, - imagePhysicalWidth, - imagePhysicalHeight, - requiredWidth, - requiredHeight - } = params; - const horizontalSpace = extractHorizontalSpace(flattenStyles); - const { - maxWidth = Infinity, - maxHeight = Infinity, - minWidth = 0, - minHeight = 0 - } = flattenStyles; - const imagesMaxWidth = - typeof contentWidth === 'number' - ? computeImagesMaxWidth(contentWidth) - : Infinity; - const minBox = { - width: minWidth, - height: minHeight - }; - const maxBox = { - width: - Math.min( - imagesMaxWidth, - maxWidth, - typeof requiredWidth === 'number' ? requiredWidth : Infinity - ) - horizontalSpace, - height: Math.min( - typeof requiredHeight === 'number' ? requiredHeight : Infinity, - maxHeight - ) - }; - if (typeof requiredWidth === 'number' && typeof requiredHeight === 'number') { - return scale( - { minBox, maxBox }, - { - width: requiredWidth, - height: requiredHeight - } - ); - } - if (imagePhysicalWidth != null && imagePhysicalHeight != null) { - return scale( - { minBox, maxBox }, - { - width: imagePhysicalWidth, - height: imagePhysicalHeight - } - ); - } - return null; -} - -interface State { - requiredWidth: number | null; - requiredHeight: number | null; - imagePhysicalWidth: number | null; - imagePhysicalHeight: number | null; - imageBoxDimensions: ImageDimensions | null; - error: boolean; -} - -const IMGElement = class HTMLImageElement extends PureComponent< - IMGElementProps, - State -> { - private __cachedFlattenStyles!: Record; - private __cachedRequirements!: IncompleteImgDimensions; - private __cachedPhysicalDimensionsFromProps!: IncompleteImgDimensions; - private mounted = false; - - constructor(props: IMGElementProps) { - super(props); - this.invalidateRequirements(props); - const state = { - imagePhysicalWidth: this.__cachedPhysicalDimensionsFromProps.width, - imagePhysicalHeight: this.__cachedPhysicalDimensionsFromProps.height, - requiredWidth: this.__cachedRequirements.width, - requiredHeight: this.__cachedRequirements.height, - imageBoxDimensions: null, - error: false - }; - this.state = { - ...state, - imageBoxDimensions: this.computeImageBoxDimensions(props, state) - }; - } - - static propTypes: Record = { - source: PropTypes.object.isRequired, - alt: PropTypes.string, - height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), - computeImagesMaxWidth: PropTypes.func.isRequired, - contentWidth: PropTypes.number, - enableExperimentalPercentWidth: PropTypes.bool, - imagesInitialDimensions: PropTypes.shape({ - width: PropTypes.number, - height: PropTypes.number - }), - altColor: PropTypes.string, - onPress: PropTypes.func, - testID: PropTypes.string - }; - - static defaultProps = { - enableExperimentalPercentWidth: false, - computeImagesMaxWidth: identity, - imagesInitialDimensions: { - width: 100, - height: 100 - }, - style: {} - }; - - invalidateRequirements(props: IMGElementProps) { - const { contentWidth, enableExperimentalPercentWidth, style } = props; - const physicalDimensionsFromProps = derivePhysicalDimensionsFromProps( - props - ); - this.__cachedFlattenStyles = StyleSheet.flatten(style) || emptyObject; - this.__cachedPhysicalDimensionsFromProps = physicalDimensionsFromProps; - this.__cachedRequirements = deriveRequiredDimensionsFromProps({ - contentWidth, - enablePercentWidth: enableExperimentalPercentWidth, - flatStyle: this.__cachedFlattenStyles, - physicalDimensionsFromProps - }); - } - - computeImageBoxDimensions(props: IMGElementProps, state: any) { - const { computeImagesMaxWidth, contentWidth } = props; - const { - imagePhysicalWidth, - imagePhysicalHeight, - requiredWidth, - requiredHeight - } = state; - const imageBoxDimensions = computeImageBoxDimensions({ - flattenStyles: this.__cachedFlattenStyles, - computeImagesMaxWidth, - contentWidth, - imagePhysicalWidth, - imagePhysicalHeight, - requiredWidth, - requiredHeight - }); - return imageBoxDimensions; - } - - componentDidMount() { - this.mounted = true; - this.fetchPhysicalImageDimensions(); - } - - componentWillUnmount() { - this.mounted = false; +const IMGElement = ({ onPress, testID, ...props }: IMGElementProps) => { + const state = useIMGElementLoader(props); + let content: ReactNode = false; + if (state.type === 'success') { + content = React.createElement(IMGElementContentSuccess, state); + } else if (state.type === 'loading') { + content = React.createElement(IMGElementContentLoading, state); + } else { + content = React.createElement(IMGElementContentError, state); } - - componentDidUpdate(prevProps: IMGElementProps, prevState: State) { - const sourceHasChanged = !sourcesAreEqual( - prevProps.source, - this.props.source - ); - const requirementsHaveChanged = - prevProps.width !== this.props.width || - prevProps.height !== this.props.height || - prevProps.style !== this.props.style; - const shouldRecomputeImageBox = - requirementsHaveChanged || - this.state.imagePhysicalWidth !== prevState.imagePhysicalWidth || - this.state.imagePhysicalHeight !== prevState.imagePhysicalHeight || - this.props.contentWidth !== prevProps.contentWidth || - this.props.computeImagesMaxWidth !== prevProps.computeImagesMaxWidth; - - if (requirementsHaveChanged) { - this.invalidateRequirements(this.props); - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - requiredWidth: this.__cachedRequirements!.width, - requiredHeight: this.__cachedRequirements!.height - }); - } - if (sourceHasChanged) { - if ( - this.__cachedRequirements!.width === null || - this.__cachedRequirements!.height === null - ) { - this.fetchPhysicalImageDimensions(); - } - } - if (shouldRecomputeImageBox) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState((state: any, props: IMGElementProps) => ({ - imageBoxDimensions: this.computeImageBoxDimensions(props, state) - })); - } - } - - fetchPhysicalImageDimensions(props = this.props) { - const { source } = props; - const shouldFetchFromImgAPI = !!source?.uri; - if ( - this.__cachedPhysicalDimensionsFromProps.width != null && - this.__cachedPhysicalDimensionsFromProps.height != null - ) { - this.setState({ - imagePhysicalWidth: this.__cachedPhysicalDimensionsFromProps.width, - imagePhysicalHeight: this.__cachedPhysicalDimensionsFromProps.height - }); - } else if (shouldFetchFromImgAPI && source.uri) { - Image.getSize( - source.uri, - (imagePhysicalWidth, imagePhysicalHeight) => { - this.mounted && - this.setState({ - imagePhysicalWidth, - imagePhysicalHeight, - error: false - }); - }, - () => { - this.mounted && this.setState({ error: true }); - } - ); - } - } - - renderImage(imageBoxDimensions: ImgDimensions, imageStyles: ImageStyle) { - const { source } = this.props; - return ( - this.setState({ error: true })} - style={[defaultImageStyle, imageBoxDimensions, imageStyles]} - testID="image-layout" - /> - ); - } - - renderAlt() { - const imageBoxDimensions = this.computeImageBoxDimensions( - this.props, - this.state - ); - return ( - - {this.props.alt ? ( - - {this.props.alt} - - ) : ( - false - )} - - ); - } - - renderPlaceholder() { - return ( - - ); - } - - renderContent(imgStyles: ImageStyle) { - const { error, imageBoxDimensions } = this.state; - if (error) { - return this.renderAlt(); - } - if (imageBoxDimensions === null) { - return this.renderPlaceholder(); - } - return this.renderImage(imageBoxDimensions, imgStyles); - } - - render() { - const { width, height, ...remainingStyle } = this.__cachedFlattenStyles; - const imgStyles = extractImgStyleProps(remainingStyle); - const style = [styles.container, remainingStyle]; - if (this.props.onPress) { - return ( - - {this.renderContent(imgStyles)} - - ); - } - return {this.renderContent(imgStyles)}; - } -} as ComponentClass; + return ( + + {content} + + ); +}; + +IMGElement.propTypes = { + source: PropTypes.object.isRequired, + alt: PropTypes.string, + altColor: PropTypes.string, + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + computeImagesMaxWidth: PropTypes.func.isRequired, + contentWidth: PropTypes.number, + enableExperimentalPercentWidth: PropTypes.bool, + imagesInitialDimensions: PropTypes.shape({ + width: PropTypes.number, + height: PropTypes.number + }) as any, + onPress: PropTypes.func, + testID: PropTypes.string +}; + +IMGElement.defaultProps = { + enableExperimentalPercentWidth: false, + computeImagesMaxWidth: identity, + imagesInitialDimensions: { + width: 100, + height: 100 + }, + style: {} +}; export default IMGElement; diff --git a/packages/render-html/src/elements/IMGElementContainer.tsx b/packages/render-html/src/elements/IMGElementContainer.tsx new file mode 100644 index 000000000..8f44b24ce --- /dev/null +++ b/packages/render-html/src/elements/IMGElementContainer.tsx @@ -0,0 +1,32 @@ +import React, { ComponentType, PropsWithChildren, useMemo } from 'react'; +import { View, Pressable, StyleSheet, ViewStyle } from 'react-native'; +import { IMGElementProps } from './img-types'; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignSelf: 'stretch', + justifyContent: 'center' + } +}); + +export default function IMGElementContainer({ + style, + onPress, + testID, + children +}: PropsWithChildren< + Pick & { style: ViewStyle } +>) { + const containerStyle = useMemo(() => { + const { width, height, ...remainingStyle } = style; + return [styles.container, remainingStyle]; + }, [style]); + const Container: ComponentType = + typeof onPress === 'function' ? Pressable : View; + return React.createElement( + Container, + { style: containerStyle, onPress, testID }, + children + ); +} diff --git a/packages/render-html/src/elements/IMGElementContentError.tsx b/packages/render-html/src/elements/IMGElementContentError.tsx new file mode 100644 index 000000000..e76e7e6cf --- /dev/null +++ b/packages/render-html/src/elements/IMGElementContentError.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { IMGElementStateError } from './img-types'; + +const styles = StyleSheet.create({ + errorBox: { + borderWidth: 1, + borderColor: 'lightgray', + overflow: 'hidden', + justifyContent: 'center' + }, + errorText: { textAlign: 'center', fontStyle: 'italic' } +}); + +export default function IMGElementContentError({ + imageBoxDimensions, + alt, + altColor +}: IMGElementStateError) { + return ( + + {alt ? ( + {alt} + ) : ( + false + )} + + ); +} diff --git a/packages/render-html/src/elements/IMGElementContentLoading.tsx b/packages/render-html/src/elements/IMGElementContentLoading.tsx new file mode 100644 index 000000000..fb21322c0 --- /dev/null +++ b/packages/render-html/src/elements/IMGElementContentLoading.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { View } from 'react-native'; +import { IMGElementStateLoading } from './img-types'; + +export default function IMGElementContentLoading({ + imageBoxDimensions +}: IMGElementStateLoading) { + return ; +} diff --git a/packages/render-html/src/elements/IMGElementContentSuccess.tsx b/packages/render-html/src/elements/IMGElementContentSuccess.tsx new file mode 100644 index 000000000..71a1b0902 --- /dev/null +++ b/packages/render-html/src/elements/IMGElementContentSuccess.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Image, ImageStyle } from 'react-native'; +import { IMGElementStateSuccess } from './img-types'; + +const defaultImageStyle: ImageStyle = { resizeMode: 'cover' }; + +export default function IMGElementContentSuccess({ + source, + imageStyle, + imageBoxDimensions +}: IMGElementStateSuccess) { + return ( + + ); +} diff --git a/packages/render-html/src/elements/__tests__/element.image.test.tsx b/packages/render-html/src/elements/__tests__/IMGElement.test.tsx similarity index 100% rename from packages/render-html/src/elements/__tests__/element.image.test.tsx rename to packages/render-html/src/elements/__tests__/IMGElement.test.tsx diff --git a/packages/render-html/src/elements/__tests__/useIMGElementLoader.test.ts b/packages/render-html/src/elements/__tests__/useIMGElementLoader.test.ts new file mode 100644 index 000000000..4ce8926d6 --- /dev/null +++ b/packages/render-html/src/elements/__tests__/useIMGElementLoader.test.ts @@ -0,0 +1,52 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { perf, wait } from 'react-performance-testing'; +import useIMGElementLoader from '../useIMGElementLoader'; + +describe('useIMGElementLoader', () => { + const props = { + contentWidth: 300, + source: { uri: 'https://foo.bar/600x300' }, + imagesInitialDimensions: { width: 30, height: 30 }, + computeImagesMaxWidth: (contentWidth: number) => contentWidth + }; + it('should render at most twice when width and height physical dimensions are not provided, prior and after fetching physical dimensions', async () => { + const { renderCount } = perf<{ TestComponent: unknown }>(React); + renderHook(() => useIMGElementLoader(props)); + await wait(() => { + expect(renderCount.current.TestComponent.value).toBeLessThan(2); + }); + }); + it('should render once when width and height physical dimensions are provided, bypassing the fetching of physical dimensions', async () => { + const { renderCount } = perf<{ TestComponent: unknown }>(React); + renderHook(() => + useIMGElementLoader({ + ...props, + width: 600, + height: 300 + }) + ); + await wait(() => { + expect(renderCount.current.TestComponent.value).toBe(1); + }); + }); + it('should start in loading state with imageBoxModel set to imagesInitialDimensions', async () => { + const { result } = renderHook(() => useIMGElementLoader(props)); + expect(result.current.type).toEqual('loading'); + expect(result.current.imageBoxDimensions).toMatchObject({ + width: 30, + height: 30 + }); + }); + it('should undate to success state with imageBoxModel set to scaled physical image dimensions', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useIMGElementLoader(props) + ); + await waitForNextUpdate(); + expect(result.current.type).toEqual('success'); + expect(result.current.imageBoxDimensions).toMatchObject({ + width: 300, + height: 150 + }); + }); +}); diff --git a/packages/render-html/src/elements/img-types.ts b/packages/render-html/src/elements/img-types.ts new file mode 100644 index 000000000..a841d4e1b --- /dev/null +++ b/packages/render-html/src/elements/img-types.ts @@ -0,0 +1,77 @@ +import { + ImageStyle, + ImageURISource, + PressableProps, + StyleProp, + ViewStyle +} from 'react-native'; + +export interface ImgDimensions { + width: number; + height: number; +} + +export interface IncompleteImgDimensions { + width: number | null; + height: number | null; +} + +export interface IMGElementLoaderProps { + alt?: string; + altColor?: string; + source: ImageURISource; + height?: string | number; + width?: string | number; + style?: StyleProp; + computeImagesMaxWidth?: (containerWidth: number) => number; + contentWidth?: number; + enableExperimentalPercentWidth?: boolean; + imagesInitialDimensions?: ImgDimensions; +} + +export interface IMGElementProps extends IMGElementLoaderProps { + style?: StyleProp; + testID?: string; + onPress?: PressableProps['onPress']; + enableExperimentalPercentWidth?: boolean; + imagesInitialDimensions?: ImgDimensions; +} + +export type IMGElementState = + | IMGElementStateError + | IMGElementStateSuccess + | IMGElementStateLoading; + +export interface IMGElementStateSuccess { + type: 'success'; + containerStyle: ViewStyle; + imageStyle: ImageStyle; + /** + * The scaled image dimensions, relative to `contentWidth`. + */ + imageBoxDimensions: ImgDimensions; + source: ImageURISource; +} + +export interface IMGElementStateLoading { + type: 'loading'; + containerStyle: ViewStyle; + /** + * The display dimensions for the image. + * This value is set to `initialImageDimensions` + * during loading. + */ + imageBoxDimensions: ImgDimensions; +} + +export interface IMGElementStateError { + type: 'error'; + containerStyle: ViewStyle; + /** + * Either the scaled image dimensions, or `initialImageDimensions` if the + * later could not be determined. + */ + imageBoxDimensions: ImgDimensions; + alt?: string; + altColor?: string; +} diff --git a/packages/render-html/src/elements/useIMGElementLoader.tsx b/packages/render-html/src/elements/useIMGElementLoader.tsx new file mode 100644 index 000000000..8b09bff0e --- /dev/null +++ b/packages/render-html/src/elements/useIMGElementLoader.tsx @@ -0,0 +1,374 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Image, ImageStyle, StyleSheet } from 'react-native'; +import pick from 'ramda/src/pick'; +import type { + IMGElementLoaderProps, + IncompleteImgDimensions, + ImgDimensions, + IMGElementState +} from './img-types'; + +function attemptParseFloat(value: any) { + const result = parseFloat(value); + return Number.isNaN(result) ? null : result; +} + +function normalizeSize( + dimension: string | number | null | undefined, + options: Partial<{ + containerDimension: number | null; + enablePercentWidth: boolean; + }> = {} +) { + const containerDimension = options.containerDimension || null; + const enablePercentWidth = options.enablePercentWidth || false; + if ( + dimension === null || + dimension === undefined || + Number.isNaN(dimension) + ) { + return null; + } + if (typeof dimension === 'number') { + return dimension; + } + if (typeof dimension === 'string') { + if ( + dimension.search('%') !== -1 && + enablePercentWidth && + typeof containerDimension === 'number' + ) { + const parsedFloat = attemptParseFloat(dimension); + if (parsedFloat === null || Number.isNaN(parsedFloat)) { + return null; + } + return (parsedFloat * containerDimension) / 100; + } else if (dimension.trim().match(/^[\d.]+$/)) { + return attemptParseFloat(dimension); + } + } + return null; +} + +function extractHorizontalSpace({ + marginHorizontal, + leftMargin, + rightMargin, + margin +}: any = {}) { + const realLeftMargin = leftMargin || marginHorizontal || margin || 0; + const realRightMargin = rightMargin || marginHorizontal || margin || 0; + return realLeftMargin + realRightMargin; +} + +function derivePhysicalDimensionsFromProps({ + width, + height, + contentWidth, + enableExperimentalPercentWidth: enablePercentWidth +}: Pick< + IMGElementLoaderProps, + 'width' | 'height' | 'contentWidth' | 'enableExperimentalPercentWidth' +>): IncompleteImgDimensions { + const normalizeOptionsWidth = { + enablePercentWidth, + containerDimension: contentWidth + }; + const normalizeOptionsHeight = { + enablePercentWidth: false + }; + const widthProp = normalizeSize(width, normalizeOptionsWidth); + const heightProp = normalizeSize(height, normalizeOptionsHeight); + return { + width: widthProp, + height: heightProp + }; +} + +function deriveRequiredDimensionsFromProps({ + enablePercentWidth, + contentWidth, + flatStyle, + physicalDimensionsFromProps +}: Pick & { + flatStyle: Record; + enablePercentWidth?: boolean; + physicalDimensionsFromProps: IncompleteImgDimensions; +}): IncompleteImgDimensions { + const normalizeOptionsWidth = { + enablePercentWidth, + containerDimension: contentWidth + }; + const normalizeOptionsHeight = { + enablePercentWidth: false + }; + const styleWidth = normalizeSize(flatStyle.width, normalizeOptionsWidth); + const styleHeight = normalizeSize(flatStyle.height, normalizeOptionsHeight); + return { + width: + typeof styleWidth === 'number' + ? styleWidth + : physicalDimensionsFromProps.width, + height: + typeof styleHeight === 'number' + ? styleHeight + : physicalDimensionsFromProps.height + }; +} + +function scaleUp( + minDimensions: ImgDimensions, + desiredDimensions: ImgDimensions +): ImgDimensions { + const aspectRatio = desiredDimensions.width / desiredDimensions.height; + if (desiredDimensions.width < minDimensions.width) { + return scaleUp(minDimensions, { + width: minDimensions.width, + height: minDimensions.width / aspectRatio + }); + } + if (desiredDimensions.height < minDimensions.height) { + return scaleUp(minDimensions, { + height: minDimensions.height, + width: minDimensions.height * aspectRatio + }); + } + return desiredDimensions; +} + +function scaleDown( + maxDimensions: ImgDimensions, + desiredDimensions: ImgDimensions +): ImgDimensions { + const aspectRatio = desiredDimensions.width / desiredDimensions.height; + if (desiredDimensions.width > maxDimensions.width) { + return scaleDown(maxDimensions, { + width: maxDimensions.width, + height: maxDimensions.width / aspectRatio + }); + } + if (desiredDimensions.height > maxDimensions.height) { + return scaleDown(maxDimensions, { + height: maxDimensions.height, + width: maxDimensions.height * aspectRatio + }); + } + return desiredDimensions; +} + +function scale( + { minBox, maxBox }: { minBox: ImgDimensions; maxBox: ImgDimensions }, + originalBox: ImgDimensions +) { + return scaleDown(maxBox, scaleUp(minBox, originalBox)); +} + +function computeImageBoxDimensions(params: any) { + const { + computeImagesMaxWidth, + contentWidth, + flattenStyles, + imagePhysicalWidth, + imagePhysicalHeight, + requiredWidth, + requiredHeight + } = params; + const horizontalSpace = extractHorizontalSpace(flattenStyles); + const { + maxWidth = Infinity, + maxHeight = Infinity, + minWidth = 0, + minHeight = 0 + } = flattenStyles; + const imagesMaxWidth = + typeof contentWidth === 'number' + ? computeImagesMaxWidth(contentWidth) + : Infinity; + const minBox = { + width: minWidth, + height: minHeight + }; + const maxBox = { + width: + Math.min( + imagesMaxWidth, + maxWidth, + typeof requiredWidth === 'number' ? requiredWidth : Infinity + ) - horizontalSpace, + height: Math.min( + typeof requiredHeight === 'number' ? requiredHeight : Infinity, + maxHeight + ) + }; + if (typeof requiredWidth === 'number' && typeof requiredHeight === 'number') { + return scale( + { minBox, maxBox }, + { + width: requiredWidth, + height: requiredHeight + } + ); + } + if (imagePhysicalWidth != null && imagePhysicalHeight != null) { + return scale( + { minBox, maxBox }, + { + width: imagePhysicalWidth, + height: imagePhysicalHeight + } + ); + } + return null; +} + +function isPlainImgDimensions( + imgDimensions: IncompleteImgDimensions +): imgDimensions is ImgDimensions { + return imgDimensions.width != null && imgDimensions.height != null; +} + +const extractImgStyleProps = pick([ + 'resizeMode', + 'tintColor', + 'overlayColor' +]); + +function usePhysicalDimensions({ + source, + contentWidth, + enableExperimentalPercentWidth, + width, + height, + style +}: IMGElementLoaderProps) { + const [ + physicalDimensions, + setPhysicalDimensions + ] = useState(null); + const physicalDimensionsFromProps = useMemo( + () => + derivePhysicalDimensionsFromProps({ + contentWidth, + enableExperimentalPercentWidth, + width, + height + }), + [contentWidth, enableExperimentalPercentWidth, height, width] + ); + const flatStyle = useMemo(() => StyleSheet.flatten(style) || {}, [style]); + const requirements = useMemo( + function computeRequirements() { + return deriveRequiredDimensionsFromProps({ + enablePercentWidth: enableExperimentalPercentWidth, + flatStyle, + contentWidth, + physicalDimensionsFromProps: physicalDimensionsFromProps + }); + }, + [ + contentWidth, + enableExperimentalPercentWidth, + flatStyle, + physicalDimensionsFromProps + ] + ); + const [hasError, setHasError] = useState(false); + useEffect( + function fetchPhysicalDimensions() { + let cancelled = false; + if (source.uri) { + Image.getSize( + source.uri, + (w, h) => { + !cancelled && setPhysicalDimensions({ width: w, height: h }); + }, + (e) => { + if (__DEV__) { + console.error(e); + } + !cancelled && setHasError(true); + } + ); + return () => { + cancelled = true; + }; + } + }, + [source.uri, physicalDimensions, physicalDimensionsFromProps] + ); + useEffect( + function resetOnURIChange() { + setPhysicalDimensions(null); + }, + [source.uri] + ); + return { + requirements, + flatStyle, + physicalDimensions: isPlainImgDimensions(physicalDimensionsFromProps) + ? physicalDimensionsFromProps + : physicalDimensions, + error: hasError + }; +} + +export default function useIMGElementLoader( + props: IMGElementLoaderProps +): IMGElementState { + const { + alt, + altColor, + source, + contentWidth, + computeImagesMaxWidth, + imagesInitialDimensions + } = props; + const { + physicalDimensions, + requirements, + flatStyle, + error + } = usePhysicalDimensions(props); + const imageBoxDimensions = useMemo(() => { + if (physicalDimensions) { + return computeImageBoxDimensions({ + flattenStyles: flatStyle, + computeImagesMaxWidth, + contentWidth, + imagePhysicalWidth: physicalDimensions?.width, + imagePhysicalHeight: physicalDimensions?.height, + requiredWidth: requirements.width, + requiredHeight: requirements.height + }); + } + return null; + }, [ + computeImagesMaxWidth, + contentWidth, + flatStyle, + physicalDimensions, + requirements.height, + requirements.width + ]); + return error + ? { + alt, + altColor, + type: 'error', + containerStyle: flatStyle, + imageBoxDimensions: imageBoxDimensions ?? imagesInitialDimensions! + } + : imageBoxDimensions + ? { + type: 'success', + containerStyle: flatStyle as any, + imageStyle: extractImgStyleProps(flatStyle), + imageBoxDimensions, + source + } + : { + type: 'loading', + containerStyle: flatStyle, + imageBoxDimensions: imagesInitialDimensions! + }; +} diff --git a/packages/render-html/src/index.ts b/packages/render-html/src/index.ts index 0040c824a..1a61a5690 100644 --- a/packages/render-html/src/index.ts +++ b/packages/render-html/src/index.ts @@ -71,3 +71,12 @@ export { export { useDocumentMetadata } from './context/DocumentMetadataProvider'; export { default as domNodeToHTMLString } from './helpers/domNodeToHTMLString'; export type { DomNodeToHtmlReporter } from './helpers/domNodeToHTMLString'; +// IMG +export { default as useIMGElementLoader } from './elements/useIMGElementLoader'; +export { default as IMGElement } from './elements/IMGElement'; +export { default as IMGElementContainer } from './elements/IMGElementContainer'; +export { default as IMGElementContentError } from './elements/IMGElementContentError'; +export { default as IMGElementContentLoading } from './elements/IMGElementContentLoading'; +export { default as IMGElementContentSuccess } from './elements/IMGElementContentSuccess'; +export * from './elements/img-types'; +export { useIMGElementProps } from './renderers/IMGRenderer'; diff --git a/yarn.lock b/yarn.lock index 7bf42ef2f..b30353e6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -116,7 +116,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-annotate-as-pure@npm:^7.10.4, @babel/helper-annotate-as-pure@npm:^7.12.13": +"@babel/helper-annotate-as-pure@npm:^7.0.0, @babel/helper-annotate-as-pure@npm:^7.10.4, @babel/helper-annotate-as-pure@npm:^7.12.13": version: 7.12.13 resolution: "@babel/helper-annotate-as-pure@npm:7.12.13" dependencies: @@ -223,7 +223,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.12.13": +"@babel/helper-module-imports@npm:^7.0.0, @babel/helper-module-imports@npm:^7.12.13": version: 7.12.13 resolution: "@babel/helper-module-imports@npm:7.12.13" dependencies: @@ -1418,7 +1418,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4": version: 7.12.13 resolution: "@babel/runtime@npm:7.12.13" dependencies: @@ -1438,7 +1438,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.0.0, @babel/traverse@npm:^7.1.0, @babel/traverse@npm:^7.12.13, @babel/traverse@npm:^7.7.0, @babel/traverse@npm:^7.7.4, @babel/traverse@npm:^7.9.0": +"@babel/traverse@npm:^7.0.0, @babel/traverse@npm:^7.1.0, @babel/traverse@npm:^7.12.13, @babel/traverse@npm:^7.4.5, @babel/traverse@npm:^7.7.0, @babel/traverse@npm:^7.7.4, @babel/traverse@npm:^7.9.0": version: 7.12.13 resolution: "@babel/traverse@npm:7.12.13" dependencies: @@ -1676,6 +1676,36 @@ __metadata: languageName: node linkType: hard +"@emotion/is-prop-valid@npm:^0.8.8": + version: 0.8.8 + resolution: "@emotion/is-prop-valid@npm:0.8.8" + dependencies: + "@emotion/memoize": 0.7.4 + checksum: 4a6993c4e6a49bcdc772aa5931fa2f00ce6367f7f6fc9cfe46dd50014c9510f9c6b1e355f297655875a8bfd1481e42546900bbbc84f3c0b629a001b4d82e436e + languageName: node + linkType: hard + +"@emotion/memoize@npm:0.7.4": + version: 0.7.4 + resolution: "@emotion/memoize@npm:0.7.4" + checksum: 874123a94c89963dda3438d1ea7f7c17fa670d965610eefaa49e0dbf47cccee6f6108e04175867d7e485d2c04096a98bba5a4bef2606b3bf2070637327ebe3ff + languageName: node + linkType: hard + +"@emotion/stylis@npm:^0.8.4": + version: 0.8.5 + resolution: "@emotion/stylis@npm:0.8.5" + checksum: bb43a77f784cce86f7a625519544aab56b8f341117957f7dd15315398780289784bd2ec0ba1bc1b19ac639bdb304a4ed08b1f8e3e4c13e8063b9824e551b3994 + languageName: node + linkType: hard + +"@emotion/unitless@npm:^0.7.4": + version: 0.7.5 + resolution: "@emotion/unitless@npm:0.7.5" + checksum: 0be366ef09860037ef08aed0450cb5510f4be25886005e2f120f8e8b7385de6b41ac47df5b9bd55781e5153853e9ed5f49aa517dcbad34cc23bd8afb0201932a + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^0.3.0": version: 0.3.0 resolution: "@eslint/eslintrc@npm:0.3.0" @@ -3361,6 +3391,29 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-hooks@npm:^5.0.3": + version: 5.0.3 + resolution: "@testing-library/react-hooks@npm:5.0.3" + dependencies: + "@babel/runtime": ^7.12.5 + "@types/react": ">=16.9.0" + "@types/react-dom": ">=16.9.0" + "@types/react-test-renderer": ">=16.9.0" + filter-console: ^0.1.1 + react-error-boundary: ^3.1.0 + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + react-test-renderer: ">=16.9.0" + peerDependenciesMeta: + react-dom: + optional: true + react-test-renderer: + optional: true + checksum: e542a768fedeb344cfba5c171a3efa0341786e3a38bb0bc56a39bf66dbd9a89f530575e34e8cf3cc006aafe0ab0908c80afd42b1079e4c5812b28b8f3f9f4523 + languageName: node + linkType: hard + "@tsconfig/react-native@npm:^1.0.2": version: 1.0.2 resolution: "@tsconfig/react-native@npm:1.0.2" @@ -3469,6 +3522,16 @@ __metadata: languageName: node linkType: hard +"@types/hoist-non-react-statics@npm:*": + version: 3.3.1 + resolution: "@types/hoist-non-react-statics@npm:3.3.1" + dependencies: + "@types/react": "*" + hoist-non-react-statics: ^3.3.0 + checksum: 16ab4c45d4920fa378c8be76554b10061247fc04d2c8af11bdb7d520b3967e9c06d7ad5efd9b0f1657fbc4d095f62c6e1325f03b9141eb1ef2c8095b96fd42f8 + languageName: node + linkType: hard + "@types/http-cache-semantics@npm:*": version: 4.0.0 resolution: "@types/http-cache-semantics@npm:4.0.0" @@ -3595,7 +3658,16 @@ __metadata: languageName: node linkType: hard -"@types/react-native@npm:^0.63.35, @types/react-native@npm:~0.63.35": +"@types/react-dom@npm:>=16.9.0": + version: 17.0.0 + resolution: "@types/react-dom@npm:17.0.0" + dependencies: + "@types/react": "*" + checksum: 7ca9351b6c2523327cd7afe40082a37034898f65e9d86700236682efed910ba7c9cec10e9a3b829aa7b804b18791a28743ce468fcb42e9a788bfc33b84137e45 + languageName: node + linkType: hard + +"@types/react-native@npm:^0.63.35, @types/react-native@npm:^0.63.8, @types/react-native@npm:~0.63.35": version: 0.63.48 resolution: "@types/react-native@npm:0.63.48" dependencies: @@ -3604,7 +3676,7 @@ __metadata: languageName: node linkType: hard -"@types/react-test-renderer@npm:^17.0.0": +"@types/react-test-renderer@npm:>=16.9.0, @types/react-test-renderer@npm:^17.0.0": version: 17.0.0 resolution: "@types/react-test-renderer@npm:17.0.0" dependencies: @@ -3613,7 +3685,7 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*": +"@types/react@npm:*, @types/react@npm:>=16.9.0": version: 17.0.1 resolution: "@types/react@npm:17.0.1" dependencies: @@ -3656,6 +3728,17 @@ __metadata: languageName: node linkType: hard +"@types/styled-components@npm:^5.1.2": + version: 5.1.7 + resolution: "@types/styled-components@npm:5.1.7" + dependencies: + "@types/hoist-non-react-statics": "*" + "@types/react": "*" + csstype: ^3.0.2 + checksum: e0ce3949cbca71581288aac9b948c9943708f7ef20bdc030c8665f88f9e829b37ae7312979d9c949200aff99a704eb22e28f06b8c7bb50d4dbe1d01894762c99 + languageName: node + linkType: hard + "@types/urijs@npm:^1.19.14": version: 1.19.14 resolution: "@types/urijs@npm:1.19.14" @@ -4537,6 +4620,27 @@ __metadata: languageName: node linkType: hard +"babel-plugin-styled-components@npm:>= 1": + version: 1.12.0 + resolution: "babel-plugin-styled-components@npm:1.12.0" + dependencies: + "@babel/helper-annotate-as-pure": ^7.0.0 + "@babel/helper-module-imports": ^7.0.0 + babel-plugin-syntax-jsx: ^6.18.0 + lodash: ^4.17.11 + peerDependencies: + styled-components: ">= 2" + checksum: 6490bd07b41dd8163c85c52cb4de18793c1bbd346c07cf2bca19a433d36e2bc9174545de2ec490927b114dc497f215295518af95511d825696c4309590e8b79b + languageName: node + linkType: hard + +"babel-plugin-syntax-jsx@npm:^6.18.0": + version: 6.18.0 + resolution: "babel-plugin-syntax-jsx@npm:6.18.0" + checksum: a5c8174ad6165d5f541f9f31cf4b6338ccfb7d586cec111537fa567f13b5fbdcf54f7928db44429d4610aa1be9d07bb03d017b22ba521ff819a6a2090b694797 + languageName: node + linkType: hard + "babel-plugin-syntax-trailing-function-commas@npm:^7.0.0-beta.0": version: 7.0.0-beta.0 resolution: "babel-plugin-syntax-trailing-function-commas@npm:7.0.0-beta.0" @@ -7800,6 +7904,13 @@ __metadata: languageName: node linkType: hard +"filter-console@npm:^0.1.1": + version: 0.1.1 + resolution: "filter-console@npm:0.1.1" + checksum: f9def51f7ece9c9c469f78e1551ce33dfde02fd32bd70a6fbe8da4a9377ebb65a2a4fc6196ebf8e6ea8668a5465bde7fe3459101d1042e73bb9e3e1eb3f22fb6 + languageName: node + linkType: hard + "finalhandler@npm:1.1.2": version: 1.1.2 resolution: "finalhandler@npm:1.1.2" @@ -8654,7 +8765,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0": +"hoist-non-react-statics@npm:^3.0.0, hoist-non-react-statics@npm:^3.3.0": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -11352,7 +11463,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"lodash@npm:4.17.20, lodash@npm:^4.17.10, lodash@npm:^4.17.13, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.4, lodash@npm:^4.3.0, lodash@npm:^4.5.0, lodash@npm:^4.6.0": +"lodash@npm:4.17.20, lodash@npm:^4.17.10, lodash@npm:^4.17.11, lodash@npm:^4.17.13, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.4, lodash@npm:^4.3.0, lodash@npm:^4.5.0, lodash@npm:^4.6.0": version: 4.17.20 resolution: "lodash@npm:4.17.20" checksum: c62101d2500c383b5f174a7e9e6fe8098149ddd6e9ccfa85f36d4789446195f5c4afd3cfba433026bcaf3da271256566b04a2bf2618e5a39f6e67f8c12030cb6 @@ -11733,6 +11844,55 @@ fsevents@^1.2.7: languageName: node linkType: hard +"metro-react-native-babel-preset@npm:^0.62.0": + version: 0.62.0 + resolution: "metro-react-native-babel-preset@npm:0.62.0" + dependencies: + "@babel/core": ^7.0.0 + "@babel/plugin-proposal-class-properties": ^7.0.0 + "@babel/plugin-proposal-export-default-from": ^7.0.0 + "@babel/plugin-proposal-nullish-coalescing-operator": ^7.0.0 + "@babel/plugin-proposal-object-rest-spread": ^7.0.0 + "@babel/plugin-proposal-optional-catch-binding": ^7.0.0 + "@babel/plugin-proposal-optional-chaining": ^7.0.0 + "@babel/plugin-syntax-dynamic-import": ^7.0.0 + "@babel/plugin-syntax-export-default-from": ^7.0.0 + "@babel/plugin-syntax-flow": ^7.2.0 + "@babel/plugin-syntax-nullish-coalescing-operator": ^7.0.0 + "@babel/plugin-syntax-optional-chaining": ^7.0.0 + "@babel/plugin-transform-arrow-functions": ^7.0.0 + "@babel/plugin-transform-block-scoping": ^7.0.0 + "@babel/plugin-transform-classes": ^7.0.0 + "@babel/plugin-transform-computed-properties": ^7.0.0 + "@babel/plugin-transform-destructuring": ^7.0.0 + "@babel/plugin-transform-exponentiation-operator": ^7.0.0 + "@babel/plugin-transform-flow-strip-types": ^7.0.0 + "@babel/plugin-transform-for-of": ^7.0.0 + "@babel/plugin-transform-function-name": ^7.0.0 + "@babel/plugin-transform-literals": ^7.0.0 + "@babel/plugin-transform-modules-commonjs": ^7.0.0 + "@babel/plugin-transform-object-assign": ^7.0.0 + "@babel/plugin-transform-parameters": ^7.0.0 + "@babel/plugin-transform-react-display-name": ^7.0.0 + "@babel/plugin-transform-react-jsx": ^7.0.0 + "@babel/plugin-transform-react-jsx-self": ^7.0.0 + "@babel/plugin-transform-react-jsx-source": ^7.0.0 + "@babel/plugin-transform-regenerator": ^7.0.0 + "@babel/plugin-transform-runtime": ^7.0.0 + "@babel/plugin-transform-shorthand-properties": ^7.0.0 + "@babel/plugin-transform-spread": ^7.0.0 + "@babel/plugin-transform-sticky-regex": ^7.0.0 + "@babel/plugin-transform-template-literals": ^7.0.0 + "@babel/plugin-transform-typescript": ^7.5.0 + "@babel/plugin-transform-unicode-regex": ^7.0.0 + "@babel/template": ^7.0.0 + react-refresh: ^0.4.0 + peerDependencies: + "@babel/core": "*" + checksum: aa5b620c2ad33b853617582cb6378295fee217e1b83bfb33b1a7e651f1a97cdb6c4ce1db485c397dc22d1654f2864733ef95a221b5d72d6baf2a5b588d691c55 + languageName: node + linkType: hard + "metro-react-native-babel-preset@npm:^0.64.0": version: 0.64.0 resolution: "metro-react-native-babel-preset@npm:0.64.0" @@ -13661,6 +13821,17 @@ fsevents@^1.2.7: languageName: node linkType: hard +"react-error-boundary@npm:^3.1.0": + version: 3.1.0 + resolution: "react-error-boundary@npm:3.1.0" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + react: ">=16.13.1" + checksum: 0df44cd3ac6fb9990b500eb052544cbf166bf5b544ca415fd8ed3c39b67daf5376c977ee03da4a34478af22809fe99a82a7ed53719b8a16c87ad53211508b461 + languageName: node + linkType: hard + "react-is@npm:^16.12.0 || ^17.0.0, react-is@npm:^17.0.1": version: 17.0.1 resolution: "react-is@npm:17.0.1" @@ -13791,6 +13962,7 @@ fsevents@^1.2.7: "@babel/runtime": ^7.12.13 "@native-html/transient-render-engine": ^4.2.0 "@release-it/conventional-changelog": ^2.0.0 + "@testing-library/react-hooks": ^5.0.3 "@types/jest": ^26.0.14 "@types/ramda": ^0.27.32 "@types/react-native": ^0.63.35 @@ -13807,6 +13979,7 @@ fsevents@^1.2.7: react-native: ^0.63.4 react-native-builder-bob: ^0.17.1 react-native-testing-library: ^6.0.0 + react-performance-testing: ^1.2.3 react-test-renderer: ^17.0.1 release-it: ^14.2.1 stringify-entities: ^3.1.0 @@ -13933,6 +14106,19 @@ fsevents@^1.2.7: languageName: node linkType: hard +"react-performance-testing@npm:^1.2.3": + version: 1.2.3 + resolution: "react-performance-testing@npm:1.2.3" + dependencies: + "@types/react-native": ^0.63.8 + "@types/styled-components": ^5.1.2 + metro-react-native-babel-preset: ^0.62.0 + react-native: ^0.63.2 + styled-components: ^5.1.1 + checksum: 6473f26726f6d12c77e1892d822e4785975cd43ea3ecb29a82c1abac75afd407a169e2ed2aaf6082a8b7c2d4749bba5891df927b9decfc5da74c51b32edac85c + languageName: node + linkType: hard + "react-refresh@npm:^0.4.0": version: 0.4.3 resolution: "react-refresh@npm:0.4.3" @@ -14950,6 +15136,13 @@ resolve@1.1.7: languageName: node linkType: hard +"shallowequal@npm:^1.1.0": + version: 1.1.0 + resolution: "shallowequal@npm:1.1.0" + checksum: 15820dd544ce15521565c366940a46dcbe0f093c1336f6259c7b3e2490ca10135645ee262778f555d3ccc38283207f2f0a41e9a0f26888b5d5159f2904c4ac68 + languageName: node + linkType: hard + "shebang-command@npm:^1.2.0": version: 1.2.0 resolution: "shebang-command@npm:1.2.0" @@ -15630,6 +15823,28 @@ resolve@1.1.7: languageName: node linkType: hard +"styled-components@npm:^5.1.1": + version: 5.2.1 + resolution: "styled-components@npm:5.2.1" + dependencies: + "@babel/helper-module-imports": ^7.0.0 + "@babel/traverse": ^7.4.5 + "@emotion/is-prop-valid": ^0.8.8 + "@emotion/stylis": ^0.8.4 + "@emotion/unitless": ^0.7.4 + babel-plugin-styled-components: ">= 1" + css-to-react-native: ^3.0.0 + hoist-non-react-statics: ^3.0.0 + shallowequal: ^1.1.0 + supports-color: ^5.5.0 + peerDependencies: + react: ">= 16.8.0" + react-dom: ">= 16.8.0" + react-is: ">= 16.8.0" + checksum: c27911be08f2c6212d1f381d154cab5204c63b34d1b9ba56388b19654497a523ff466884fbf8914637c046ae8862387b0fc63f9ead67e1a8ab3a8af70b0d8089 + languageName: node + linkType: hard + "sudo-prompt@npm:^9.0.0": version: 9.2.1 resolution: "sudo-prompt@npm:9.2.1" @@ -15637,7 +15852,7 @@ resolve@1.1.7: languageName: node linkType: hard -"supports-color@npm:^5.3.0": +"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" dependencies: