diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js
index db39c72950ef..53e1f4e2295b 100644
--- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js
+++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js
@@ -60,6 +60,15 @@ export const backgroundRepeatAttribute: AnyAttributeType = nativeCSSParsing
? true
: {process: processBackgroundRepeat};
+// The `mask-*` longhands accept the same values as their `background-*`
+// counterparts, so they reuse the same processing.
+// https://www.w3.org/TR/css-masking-1/#the-mask-image
+export const maskImageAttribute: AnyAttributeType = backgroundImageAttribute;
+export const maskSizeAttribute: AnyAttributeType = backgroundSizeAttribute;
+export const maskPositionAttribute: AnyAttributeType =
+ backgroundPositionAttribute;
+export const maskRepeatAttribute: AnyAttributeType = backgroundRepeatAttribute;
+
export const transformAttribute: AnyAttributeType = nativeCSSParsing
? true
: {process: processTransform};
@@ -223,6 +232,14 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
/** @deprecated Use `backgroundRepeat` instead. */
experimental_backgroundRepeat: backgroundRepeatAttribute,
+ /**
+ * Mask
+ */
+ maskImage: maskImageAttribute,
+ maskSize: maskSizeAttribute,
+ maskPosition: maskPositionAttribute,
+ maskRepeat: maskRepeatAttribute,
+
/**
* View
*/
diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-mask-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-mask-itest.js
new file mode 100644
index 000000000000..7ecc9717b6d5
--- /dev/null
+++ b/packages/react-native/Libraries/Components/View/__tests__/View-mask-itest.js
@@ -0,0 +1,78 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
+
+import * as Fantom from '@react-native/fantom';
+import * as React from 'react';
+import {View} from 'react-native';
+
+// `mask-image` reuses the `background-image` value syntax, so these tests check
+// that the style plumbing reaches the `maskImage` prop of the mounted view for
+// each accepted form. `collapsable={false}` is not needed here: a non-empty
+// `mask-image` makes the view form a stacking context on its own.
+function mountedMaskImage(style: ViewStyleProp): string {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+ return root.getRenderedOutput({props: ['maskImage']}).toJSONObject().props
+ .maskImage;
+}
+
+describe(' mask-image', () => {
+ it('accepts a linear-gradient() shorthand', () => {
+ const maskImage = mountedMaskImage({
+ maskImage: 'linear-gradient(#e66465, #9198e5)',
+ });
+ expect(maskImage).toContain('linear-gradient');
+ expect(maskImage).toContain('rgba(230, 100, 101, 1)');
+ });
+
+ it('accepts a radial-gradient() shorthand', () => {
+ expect(
+ mountedMaskImage({maskImage: 'radial-gradient(circle, black, white)'}),
+ ).toContain('radial-gradient');
+ });
+
+ it('accepts a url() shorthand', () => {
+ expect(
+ mountedMaskImage({maskImage: 'url(https://example.com/mask.png)'}),
+ ).toContain('https://example.com/mask.png');
+ });
+
+ it('accepts an array of layers', () => {
+ const maskImage = mountedMaskImage({
+ maskImage: [
+ {type: 'url', uri: 'https://example.com/mask.png'},
+ {
+ type: 'linear-gradient',
+ direction: 'to right',
+ colorStops: [{color: 'black'}, {color: 'white'}],
+ },
+ ],
+ });
+ expect(maskImage).toContain('https://example.com/mask.png');
+ expect(maskImage).toContain('linear-gradient');
+ });
+
+ it('is unset when no mask-image is given', () => {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+ expect(
+ root.getRenderedOutput({props: ['maskImage']}).toJSONObject().props
+ .maskImage,
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js
index 6e3ee698720d..aa9b829c0633 100644
--- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js
+++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js
@@ -19,6 +19,10 @@ import {
boxShadowAttribute,
colorAttribute,
filterAttribute,
+ maskImageAttribute,
+ maskPositionAttribute,
+ maskRepeatAttribute,
+ maskSizeAttribute,
} from '../Components/View/ReactNativeStyleAttributes';
import {DynamicallyInjectedByGestureHandler} from './ViewConfigIgnore';
@@ -219,6 +223,10 @@ const validAttributesForNonEventProps = {
experimental_backgroundPosition: backgroundPositionAttribute,
backgroundRepeat: backgroundRepeatAttribute,
experimental_backgroundRepeat: backgroundRepeatAttribute,
+ maskImage: maskImageAttribute,
+ maskSize: maskSizeAttribute,
+ maskPosition: maskPositionAttribute,
+ maskRepeat: maskRepeatAttribute,
boxShadow: boxShadowAttribute,
filter: filterAttribute,
mixBlendMode: true,
diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js
index 5a1951a1e2c6..5048ea3990e6 100644
--- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js
+++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js
@@ -769,7 +769,13 @@ type RadialGradientValue = {
}>,
};
-export type BackgroundImageValue = LinearGradientValue | RadialGradientValue;
+type URLBackgroundImageValue = {
+ type: 'url',
+ uri: string | number,
+};
+
+export type BackgroundImageValue =
+ LinearGradientValue | RadialGradientValue | URLBackgroundImageValue;
export type BackgroundSizeValue = {
x: string | number,
@@ -900,6 +906,10 @@ export type ____ViewStyle_InternalBase = Readonly<{
ReadonlyArray | string,
backgroundRepeat?: ReadonlyArray | string,
experimental_backgroundRepeat?: ReadonlyArray | string,
+ maskImage?: ReadonlyArray | string,
+ maskSize?: ReadonlyArray | string,
+ maskPosition?: ReadonlyArray | string,
+ maskRepeat?: ReadonlyArray | string,
isolation?: 'auto' | 'isolate',
}>;
diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js
index 5dc05646b27e..019f11826ef5 100644
--- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js
+++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js
@@ -363,6 +363,10 @@ describe('processBackgroundImage', () => {
{color: 'blue', positions: ['100%']},
],
},
+ {
+ type: 'url',
+ uri: 'https://example.com',
+ },
];
const result = processBackgroundImage(input);
expect(result).toEqual([
@@ -374,6 +378,10 @@ describe('processBackgroundImage', () => {
{color: processColor('blue'), position: '100%'},
],
},
+ {
+ type: 'url',
+ uri: 'https://example.com',
+ },
]);
});
@@ -1188,4 +1196,104 @@ describe('processBackgroundImage', () => {
expect(result1).toEqual([]);
expect(result2).toEqual([]);
});
+
+ it('should parse url unquoted', () => {
+ const result = processBackgroundImage('url(https://example.com/image.png)');
+ expect(result).toEqual([
+ {type: 'url', uri: 'https://example.com/image.png'},
+ ]);
+ });
+
+ it('should parse url double quoted', () => {
+ const result = processBackgroundImage(
+ 'url("https://example.com/image.png")',
+ );
+ expect(result).toEqual([
+ {type: 'url', uri: 'https://example.com/image.png'},
+ ]);
+ });
+
+ it('should parse url single quoted', () => {
+ const result = processBackgroundImage(
+ "url('https://example.com/image.png')",
+ );
+ expect(result).toEqual([
+ {type: 'url', uri: 'https://example.com/image.png'},
+ ]);
+ });
+
+ it('should parse url case insensitive', () => {
+ const result = processBackgroundImage('UrL(https://example.com/image.png)');
+ expect(result).toEqual([
+ {type: 'url', uri: 'https://example.com/image.png'},
+ ]);
+ });
+
+ it('should parse url with query params', () => {
+ const result = processBackgroundImage(
+ 'url(https://example.com/image.png?size=Large&format=webp)',
+ );
+ expect(result).toEqual([
+ {
+ type: 'url',
+ uri: 'https://example.com/image.png?size=Large&format=webp',
+ },
+ ]);
+ });
+
+ it('should parse url with whitespace', () => {
+ const result = processBackgroundImage(
+ 'url( https://example.com/image.png )',
+ );
+ expect(result).toEqual([
+ {type: 'url', uri: 'https://example.com/image.png'},
+ ]);
+ });
+
+ it('should parse multiple urls', () => {
+ const result = processBackgroundImage(
+ 'url(https://example.com/bg1.png), url(https://example.com/bg2.png)',
+ );
+ expect(result).toEqual([
+ {type: 'url', uri: 'https://example.com/bg1.png'},
+ {type: 'url', uri: 'https://example.com/bg2.png'},
+ ]);
+ });
+
+ it('should parse url mixed with gradients', () => {
+ const result = processBackgroundImage(
+ 'radial-gradient(circle at top left, red, blue), url(https://example.com/image.png), linear-gradient(to bottom, green, yellow)',
+ );
+ expect(result).toEqual([
+ {
+ type: 'radial-gradient',
+ shape: 'circle',
+ size: 'farthest-corner',
+ position: {top: '0%', left: '0%'},
+ colorStops: [
+ {color: processColor('red'), position: null},
+ {color: processColor('blue'), position: null},
+ ],
+ },
+ {type: 'url', uri: 'https://example.com/image.png'},
+ {
+ type: 'linear-gradient',
+ direction: {type: 'angle', value: 180},
+ colorStops: [
+ {color: processColor('green'), position: null},
+ {color: processColor('yellow'), position: null},
+ ],
+ },
+ ]);
+ });
+
+ it('should return empty for url empty', () => {
+ const result = processBackgroundImage('url()');
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty for url empty quoted', () => {
+ const result = processBackgroundImage('url("")');
+ expect(result).toEqual([]);
+ });
});
diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js
index 23c0b20cc525..c67fae3e50aa 100644
--- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js
+++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js
@@ -18,6 +18,7 @@ import type {
RadialGradientSize,
} from './StyleSheetTypes';
+const resolveAssetSource = require('../Image/resolveAssetSource').default;
const processColor = require('./processColor').default;
// Pre-compiled regex patterns for performance - avoids regex compilation on each call
@@ -70,13 +71,20 @@ type RadialGradientBackgroundImage = {
}>,
};
+type URLBackgroundImage = {
+ type: 'url',
+ uri: string,
+};
+
// null color indicate that the transition hint syntax is used. e.g. red, 20%, blue
type ColorStopColor = ProcessedColorValue | null;
// percentage or pixel value
type ColorStopPosition = number | string | null;
type ParsedBackgroundImageValue =
- LinearGradientBackgroundImage | RadialGradientBackgroundImage;
+ | LinearGradientBackgroundImage
+ | RadialGradientBackgroundImage
+ | URLBackgroundImage;
export default function processBackgroundImage(
backgroundImage: ?(ReadonlyArray | string),
@@ -92,6 +100,28 @@ export default function processBackgroundImage(
);
} else if (Array.isArray(backgroundImage)) {
for (const bgImage of backgroundImage) {
+ if (bgImage.type === 'url') {
+ let uri: ?string = null;
+ if (typeof bgImage.uri === 'string') {
+ uri = bgImage.uri;
+ } else if (typeof bgImage.uri === 'number') {
+ const source = resolveAssetSource(bgImage.uri);
+ if (source != null && source.uri != null) {
+ uri = source.uri;
+ }
+ }
+ if (uri != null) {
+ result = result.concat({
+ type: 'url',
+ uri,
+ });
+ continue;
+ } else {
+ // If the URI is invalid, return an empty array. Same as web.
+ return [];
+ }
+ }
+
const processedColorStops = processColorStops(bgImage);
if (processedColorStops == null) {
// If a color stop is invalid, return an empty array and do not apply any gradient. Same as web.
@@ -257,11 +287,28 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{
function parseBackgroundImageCSSString(
cssString: string,
-): ReadonlyArray {
- const gradients = [];
+): ReadOnlyArray {
+ const backgroundImages = [];
const bgImageStrings = splitGradients(cssString);
for (const bgImageString of bgImageStrings) {
+ const urlRegex = /^url\((.*)\)$/i;
+ const urlMatch = urlRegex.exec(bgImageString);
+ if (urlMatch) {
+ let uri = urlMatch[1].trim();
+ const first = uri[0];
+ if ((first === '"' || first === "'") && uri.endsWith(first)) {
+ uri = uri.slice(1, -1);
+ }
+ if (uri.length > 0) {
+ backgroundImages.push({
+ type: 'url',
+ uri,
+ });
+ }
+ continue;
+ }
+
const bgImage = bgImageString.toLowerCase();
const match = GRADIENT_REGEX.exec(bgImage);
if (match) {
@@ -272,11 +319,11 @@ function parseBackgroundImageCSSString(
: parseLinearGradientCSSString(gradientContent);
if (gradient != null) {
- gradients.push(gradient);
+ backgroundImages.push(gradient);
}
}
}
- return gradients;
+ return backgroundImages;
}
function parseRadialGradientCSSString(
diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift
index 25a126e0cce7..fd6f41ca1213 100644
--- a/packages/react-native/Package.swift
+++ b/packages/react-native/Package.swift
@@ -455,6 +455,7 @@ let reactCore = RNTarget(
let reactFabric = RNTarget(
name: .reactFabric,
path: "ReactCommon/react/renderer",
+ searchPaths: ["ReactCommon/react/renderer/imagemanager/platform/ios"],
excludedPaths: [
"animated/tests",
"animations/tests",
diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTBackgroundImageURLLoader.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTBackgroundImageURLLoader.h
new file mode 100644
index 000000000000..ec99ac1b5cd3
--- /dev/null
+++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTBackgroundImageURLLoader.h
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+#import
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol RCTBackgroundImageURLLoaderDelegate
+
+- (void)backgroundImagesDidLoad;
+
+@end
+
+@interface RCTBackgroundImageURLLoader : NSObject
+
+@property (nonatomic, weak) id delegate;
+
+- (void)updateStateWithNewState:(facebook::react::ViewShadowNode::ConcreteState::Shared)state oldState:(facebook::react::ViewShadowNode::ConcreteState::Shared)oldState;
+- (nullable UIImage *)loadedImageForUri:(NSString *)uri;
+- (void)reset;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTBackgroundImageURLLoader.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTBackgroundImageURLLoader.mm
new file mode 100644
index 000000000000..4fac0c728278
--- /dev/null
+++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTBackgroundImageURLLoader.mm
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+#import "RCTBackgroundImageURLLoader.h"
+
+#import
+#import
+#import
+
+#include