From a3c2c3960b39a1c6d6a5418600b30ba7aefc84cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Mon, 29 Dec 2025 18:07:22 +0530 Subject: [PATCH 1/8] url() function support in background image parser lint fix add local image example --- .../Libraries/StyleSheet/StyleSheetTypes.js | 11 +- .../__tests__/processBackgroundImage-itest.js | 126 +++++++++++++++- .../StyleSheet/processBackgroundImage.js | 79 +++++++++- .../view/BackgroundImagePropsConversions.cpp | 43 ++++++ .../react/renderer/css/CSSBackgroundImage.h | 49 +++++- .../ReactCommon/react/renderer/css/CSSToken.h | 4 + .../react/renderer/css/CSSTokenizer.h | 128 ++++++++++++++++ .../css/tests/CSSBackgroundImageTest.cpp | 130 ++++++++++++++++ .../renderer/css/tests/CSSTokenizerTest.cpp | 121 +++++++++++++++ .../renderer/graphics/BackgroundImage.cpp | 9 ++ .../react/renderer/graphics/BackgroundImage.h | 37 ++++- packages/react-native/ReactNativeApi.d.ts | 139 +++++++++--------- .../Libraries/StyleSheet/StyleSheetTypes.d.ts | 8 +- .../BackgroundImage/BackgroundImageExample.js | 40 +++++ .../api-snapshots/ReactAndroidDebugCxx.api | 11 +- .../ReactAndroidFrameworksCxx.api | 26 +++- .../api-snapshots/ReactAndroidNewarchCxx.api | 11 +- .../api-snapshots/ReactAndroidReleaseCxx.api | 11 +- .../api-snapshots/ReactAppleDebugCxx.api | 10 +- .../api-snapshots/ReactAppleFrameworksCxx.api | 25 +++- .../api-snapshots/ReactAppleNewarchCxx.api | 10 +- .../api-snapshots/ReactAppleReleaseCxx.api | 10 +- .../api-snapshots/ReactCommonDebugCxx.api | 10 +- .../ReactCommonFrameworksCxx.api | 25 +++- .../api-snapshots/ReactCommonNewarchCxx.api | 10 +- .../api-snapshots/ReactCommonReleaseCxx.api | 10 +- 26 files changed, 1000 insertions(+), 93 deletions(-) diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 0e926394122a..40cb689af914 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -769,7 +769,16 @@ type RadialGradientValue = { }>, }; -export type BackgroundImageValue = LinearGradientValue | RadialGradientValue; +type URLBackgroundImageValue = { + type: 'url', + uri: string | number, +}; + +export type GradientBackgroundImageValue = + LinearGradientValue | RadialGradientValue; + +export type BackgroundImageValue = + GradientBackgroundImageValue | URLBackgroundImageValue; export type BackgroundSizeValue = { x: string | number, diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 5dc05646b27e..7eded27e1733 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', + }, ]); }); @@ -657,7 +665,11 @@ describe('processBackgroundImage', () => { ], }, ]); - expect(result[0].colorStops[0].color).toEqual({ + const layer = result[0]; + if (layer.type !== 'linear-gradient') { + throw new Error(`Expected a linear-gradient, got ${layer.type}`); + } + expect(layer.colorStops[0].color).toEqual({ semantic: ['systemRedColor'], }); }); @@ -674,7 +686,11 @@ describe('processBackgroundImage', () => { ], }, ]); - expect(result[0].colorStops[0].color).toEqual({ + const layer = result[0]; + if (layer.type !== 'linear-gradient') { + throw new Error(`Expected a linear-gradient, got ${layer.type}`); + } + expect(layer.colorStops[0].color).toEqual({ dynamic: {light: 0xff000000, dark: 0xffffffff}, }); }); @@ -696,7 +712,11 @@ describe('processBackgroundImage', () => { ], }, ]); - expect(result[0].colorStops[0].color).toEqual({ + const layer = result[0]; + if (layer.type !== 'linear-gradient') { + throw new Error(`Expected a linear-gradient, got ${layer.type}`); + } + expect(layer.colorStops[0].color).toEqual({ resource_paths: ['?attr/colorPrimary'], }); }); @@ -1188,4 +1208,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..4f471265d309 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -13,16 +13,19 @@ import type {ProcessedColorValue} from './processColor'; import type { BackgroundImageValue, + GradientBackgroundImageValue, RadialGradientPosition, RadialGradientShape, 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 const NEWLINE_REGEX = /\n/g; const GRADIENT_REGEX = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/; +const URL_REGEX = /^url\((.*)\)$/i; const COMMA_SPLIT_REGEX = /,(?![^(]*\))/; const WHITESPACE_SPLIT_REGEX = /\s+/; const COLOR_STOP_PARTS_REGEX = /\S+\([^)]*\)|\S+/g; @@ -70,13 +73,24 @@ type RadialGradientBackgroundImage = { }>, }; +type URLBackgroundImage = { + type: 'url', + uri: string, + // Natural size in layout units, known only for bundled assets. Native falls + // back to the decoded image's own size when these are absent. + intrinsicWidth?: number, + intrinsicHeight?: number, +}; + // 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 +106,41 @@ export default function processBackgroundImage( ); } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { + if (bgImage.type === 'url') { + let uri: ?string = null; + let intrinsicWidth: ?number = null; + let intrinsicHeight: ?number = 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; + // The asset registry records these already divided by the asset's + // scale, so they are layout units and need no density maths native + // side. Matches how `Image` derives its default dimensions. + if (typeof source.width === 'number') { + intrinsicWidth = source.width; + } + if (typeof source.height === 'number') { + intrinsicHeight = source.height; + } + } + } + if (uri != null) { + // The registry reports both dimensions or neither. + result = result.concat( + intrinsicWidth != null && intrinsicHeight != null + ? {type: 'url', uri, intrinsicWidth, intrinsicHeight} + : {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. @@ -191,7 +240,9 @@ export default function processBackgroundImage( return result; } -function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ +function processColorStops( + bgImage: GradientBackgroundImageValue, +): ReadonlyArray<{ color: ColorStopColor, position: ColorStopPosition, }> | null { @@ -258,10 +309,28 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ function parseBackgroundImageCSSString( cssString: string, ): ReadonlyArray { - const gradients = []; + const backgroundImages: Array = []; const bgImageStrings = splitGradients(cssString); for (const bgImageString of bgImageStrings) { + const urlMatch = URL_REGEX.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) { + // A url() in a css string is not an asset, so its natural size is only + // known once the image is decoded. + backgroundImages.push({ + type: 'url', + uri, + }); + } + continue; + } + const bgImage = bgImageString.toLowerCase(); const match = GRADIENT_REGEX.exec(bgImage); if (match) { @@ -272,11 +341,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/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp index d72c64a2548b..9ac8c9390e3b 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp @@ -208,6 +208,24 @@ void parseProcessedBackgroundImage( } backgroundImage.emplace_back(std::move(radialGradient)); + } else if (type == "url") { + auto uriIt = rawBackgroundImageMap.find("uri"); + if (uriIt != rawBackgroundImageMap.end() && + uriIt->second.hasType()) { + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = (std::string)(uriIt->second); + auto widthIt = rawBackgroundImageMap.find("intrinsicWidth"); + if (widthIt != rawBackgroundImageMap.end() && + widthIt->second.hasType()) { + urlBackgroundImage.intrinsicWidth = (Float)(widthIt->second); + } + auto heightIt = rawBackgroundImageMap.find("intrinsicHeight"); + if (heightIt != rawBackgroundImageMap.end() && + heightIt->second.hasType()) { + urlBackgroundImage.intrinsicHeight = (Float)(heightIt->second); + } + backgroundImage.emplace_back(std::move(urlBackgroundImage)); + } } } @@ -409,6 +427,24 @@ void parseUnprocessedBackgroundImageList( } backgroundImage.emplace_back(std::move(radialGradient)); + } else if (type == "url") { + auto uriIt = rawBackgroundImageMap.find("uri"); + if (uriIt != rawBackgroundImageMap.end() && + uriIt->second.hasType()) { + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = (std::string)(uriIt->second); + auto widthIt = rawBackgroundImageMap.find("intrinsicWidth"); + if (widthIt != rawBackgroundImageMap.end() && + widthIt->second.hasType()) { + urlBackgroundImage.intrinsicWidth = (Float)(widthIt->second); + } + auto heightIt = rawBackgroundImageMap.find("intrinsicHeight"); + if (heightIt != rawBackgroundImageMap.end() && + heightIt->second.hasType()) { + urlBackgroundImage.intrinsicHeight = (Float)(heightIt->second); + } + backgroundImage.emplace_back(std::move(urlBackgroundImage)); + } } } @@ -484,6 +520,13 @@ void fromCSSColorStop( std::optional fromCSSBackgroundImage( const CSSBackgroundImage& cssBackgroundImage) { + if (std::holds_alternative(cssBackgroundImage)) { + const auto& urlFunc = std::get(cssBackgroundImage); + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = urlFunc.url; + return BackgroundImage{urlBackgroundImage}; + } + if (std::holds_alternative(cssBackgroundImage)) { const auto& gradient = std::get(cssBackgroundImage); diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h index 4f45e0c752c0..57c2fc673b79 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h @@ -823,11 +823,58 @@ struct CSSDataTypeParser { static_assert(CSSDataType); +struct CSSURLFunction { + std::string url{}; + + bool operator==(const CSSURLFunction &rhs) const = default; +}; + +template <> +struct CSSDataTypeParser { + static constexpr std::optional consumePreservedToken(const CSSPreservedToken &token) + { + if (token.type() != CSSTokenType::Url || token.stringValue().empty()) { + return {}; + } + + return CSSURLFunction{std::string(token.stringValue())}; + } + + /* + * A quoted url stays an ordinary function block holding a . + */ + static auto consumeFunctionBlock(const CSSFunctionBlock &func, CSSValueParser &parser) -> std::optional + { + if (!iequals(func.name, "url")) { + return {}; + } + + auto &syntaxParser = parser.syntaxParser(); + syntaxParser.consumeWhitespace(); + + auto url = syntaxParser.consumeComponentValue>( + [](const CSSPreservedToken &token) -> std::optional { + if (token.type() != CSSTokenType::String) { + return {}; + } + return std::string(token.stringValue()); + }); + + if (!url.has_value() || url->empty()) { + return {}; + } + + return CSSURLFunction{*url}; + } +}; + +static_assert(CSSDataType); + /** * Representation of * https://www.w3.org/TR/css-backgrounds-3/#background-image */ -using CSSBackgroundImage = CSSCompoundDataType; +using CSSBackgroundImage = CSSCompoundDataType; /** * Representation of diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSToken.h b/packages/react-native/ReactCommon/react/renderer/css/CSSToken.h index 9c22245b1216..3caa79e5886c 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSToken.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSToken.h @@ -34,6 +34,10 @@ enum class CSSTokenType { Percentage, WhiteSpace, Hash, + String, + BadString, + Url, + BadUrl, }; /* diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSTokenizer.h b/packages/react-native/ReactCommon/react/renderer/css/CSSTokenizer.h index f8320b03573f..635d95075522 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSTokenizer.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSTokenizer.h @@ -14,6 +14,7 @@ #include #include +#include namespace facebook::react { @@ -41,6 +42,9 @@ class CSSTokenizer { } switch (nextChar) { + case '"': + case '\'': + return consumeStringToken(nextChar); case '(': return consumeCharacter(CSSTokenType::OpenParen); case ')': @@ -187,12 +191,124 @@ class CSSTokenizer { if (peek() == '(') { advance(); consumeRunningValue(); + + // Handle e.g. url(http://a.com/1.png) + if (iequals(ident.stringValue(), "url")) { + while (isWhitespace(peek())) { + advance(); + } + consumeRunningValue(); + + // A quoted body is a inside an ordinary function block. + // Everything else is a + if (peek() != '"' && peek() != '\'') { + return consumeUrlToken(); + } + } + return {CSSTokenType::Function, ident.stringValue()}; } return ident; } + constexpr CSSToken consumeUrlToken() + { + // https://www.w3.org/TR/css-syntax-3/#consume-url-token + while (isWhitespace(peek())) { + advance(); + } + consumeRunningValue(); + + while (true) { + char next = peek(); + + if (next == ')') { + auto value = consumeRunningValue(); + advance(); + consumeRunningValue(); + return {CSSTokenType::Url, value}; + } + + if (next == '\0') { + return {CSSTokenType::Url, consumeRunningValue()}; + } + + if (isWhitespace(next)) { + auto value = consumeRunningValue(); + while (isWhitespace(peek())) { + advance(); + } + consumeRunningValue(); + if (peek() == ')') { + advance(); + consumeRunningValue(); + return {CSSTokenType::Url, value}; + } + if (peek() == '\0') { + return {CSSTokenType::Url, value}; + } + // Whitespace inside an unquoted url is not allowed. + return consumeRemnantsOfBadUrl(); + } + + if (next == '"' || next == '\'' || next == '(' || isNonPrintable(next)) { + return consumeRemnantsOfBadUrl(); + } + + advance(); + } + } + + constexpr CSSToken consumeStringToken(char endingCodePoint) + { + // https://www.w3.org/TR/css-syntax-3/#consume-string-token + advance(); + consumeRunningValue(); + + while (true) { + char next = peek(); + + if (next == endingCodePoint) { + auto value = consumeRunningValue(); + advance(); + consumeRunningValue(); + return {CSSTokenType::String, value}; + } + + if (next == '\0') { + // Parse error, but the string is still returned. + return {CSSTokenType::String, consumeRunningValue()}; + } + + if (isNewline(next)) { + // Reconsume the newline so it tokenizes normally. + consumeRunningValue(); + return CSSToken{CSSTokenType::BadString}; + } + + advance(); + } + } + + constexpr CSSToken consumeRemnantsOfBadUrl() + { + // https://www.w3.org/TR/css-syntax-3/#consume-remnants-of-bad-url + while (true) { + char next = peek(); + if (next == ')') { + advance(); + consumeRunningValue(); + return CSSToken{CSSTokenType::BadUrl}; + } + if (next == '\0') { + consumeRunningValue(); + return CSSToken{CSSTokenType::BadUrl}; + } + advance(); + } + } + constexpr CSSToken consumeIdentSequence() { // https://www.w3.org/TR/css-syntax-3/#consume-an-ident-sequence @@ -246,6 +362,18 @@ class CSSTokenizer { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } + static constexpr bool isNewline(char c) + { + // https://www.w3.org/TR/css-syntax-3/#newline + return c == '\n' || c == '\r' || c == '\f'; + } + + static constexpr bool isNonPrintable(char c) + { + // https://www.w3.org/TR/css-syntax-3/#non-printable-code-point + return (c >= '\0' && c <= '\x08') || c == '\x0B' || (c >= '\x0E' && c <= '\x1F') || c == '\x7F'; + } + std::string_view remainingCharacters_; size_t position_{0}; }; diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp index 9851cc87277f..79546501bb88 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp @@ -557,4 +557,134 @@ TEST_F(CSSBackgroundImageTest, RadialGradientNegativeRadius) { } } +TEST_F(CSSBackgroundImageTest, URLBasicUnquoted) { + auto result = + parseCSSProperty("url(https://example.com/image.png)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLDoubleQuoted) { + auto result = + parseCSSProperty("url(\"https://example.com/image.png\")"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLSingleQuoted) { + auto result = + parseCSSProperty("url('https://example.com/image.png')"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLCaseInsensitive) { + auto result = + parseCSSProperty("UrL(https://example.com/image.png)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithQueryParams) { + auto result = + parseCSSProperty("url(https://example.com/image.png?size=Large&format=webp)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png?size=Large&format=webp"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithWhitespace) { + auto result = + parseCSSProperty("url( https://example.com/image.png )"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, MultipleURLs) { + auto result = parseCSSProperty( + "url(https://example.com/bg1.png), url(https://example.com/bg2.png)"); + decltype(result) expected = CSSBackgroundImageList{ + {CSSURLFunction{.url = "https://example.com/bg1.png"}, + CSSURLFunction{.url = "https://example.com/bg2.png"}}}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLMixedWithGradients) { + auto result = parseCSSProperty( + "radial-gradient(circle at top left, red, blue), url(https://example.com/image.png), linear-gradient(to bottom, green, yellow)"); + decltype(result) expected = CSSBackgroundImageList{ + {CSSRadialGradientFunction{ + .shape = CSSRadialGradientShape::Circle, + .size = CSSRadialGradientSizeKeyword::FarthestCorner, + .position = + CSSRadialGradientPosition{ + .top = CSSPercentage{.value = 0.0f}, + .left = CSSPercentage{.value = 0.0f}}, + .items = {makeCSSColorStop(255, 0, 0), makeCSSColorStop(0, 0, 255)}}, + CSSURLFunction{.url = "https://example.com/image.png"}, + CSSLinearGradientFunction{ + .direction = + CSSLinearGradientDirection{.value = CSSAngle{.degrees = 180.0f}}, + .items = { + makeCSSColorStop(0, 128, 0), makeCSSColorStop(255, 255, 0)}}}}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLEmpty) { + auto result = parseCSSProperty("url()"); + ASSERT_TRUE(std::holds_alternative(result)); +} + +TEST_F(CSSBackgroundImageTest, URLEmptyQuoted) { + auto result = parseCSSProperty("url(\"\")"); + ASSERT_TRUE(std::holds_alternative(result)); +} + +TEST_F(CSSBackgroundImageTest, URLWithPort) { + auto result = parseCSSProperty("url(http://localhost:8081/assets/bunny.png)"); + decltype(result) expected = CSSURLFunction{.url = "http://localhost:8081/assets/bunny.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithNumericQueryValues) { + auto result = parseCSSProperty("url(https://example.com/x.png?w=100&h=200)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/x.png?w=100&h=200"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithNumericPathSegment) { + auto result = parseCSSProperty("url(https://example.com/img/2024/photo.png)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/img/2024/photo.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithPercentEncoding) { + auto result = parseCSSProperty("url(https://example.com/my%20file.png)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/my%20file.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLQuotedWithPort) { + auto result = parseCSSProperty("url(\"http://localhost:8081/1.png\")"); + decltype(result) expected = CSSURLFunction{.url = "http://localhost:8081/1.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLQuotedWithNumericQueryValues) { + auto result = parseCSSProperty("url('https://example.com/x.png?w=100&h=200')"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/x.png?w=100&h=200"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLBadUrlIsInvalid) { + // Whitespace inside an unquoted url yields a . + auto result = parseCSSProperty("url(my file.png)"); + ASSERT_TRUE(std::holds_alternative(result)); +} + +TEST_F(CSSBackgroundImageTest, URLWithFragment) { + auto result = parseCSSProperty("url(https://example.com/sprite.svg#icon)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/sprite.svg#icon"}; + ASSERT_EQ(result, expected); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTokenizerTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTokenizerTest.cpp index 33b67e14b32a..463bc5a3dd93 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTokenizerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTokenizerTest.cpp @@ -239,4 +239,125 @@ TEST(CSSTokenizer, hash_values) { CSSToken{CSSTokenType::Delim, "*"}, CSSToken{CSSTokenType::EndOfFile}); } + +// https://www.w3.org/TR/css-syntax-3/#consume-string-token + +TEST(CSSTokenizer, string_values) { + EXPECT_TOKENS( + "\"hello\"", + CSSToken{CSSTokenType::String, "hello"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "'hello'", + CSSToken{CSSTokenType::String, "hello"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "\"it's\"", + CSSToken{CSSTokenType::String, "it's"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "\"http://localhost:8081/1.png\"", + CSSToken{CSSTokenType::String, "http://localhost:8081/1.png"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "\"\"", + CSSToken{CSSTokenType::String, ""}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "\"abc", + CSSToken{CSSTokenType::String, "abc"}, + CSSToken{CSSTokenType::EndOfFile}); +} + +TEST(CSSTokenizer, bad_string_values) { + EXPECT_TOKENS( + "\"abc\ndef\"", + CSSToken{CSSTokenType::BadString}, + CSSToken{CSSTokenType::WhiteSpace}, + CSSToken{CSSTokenType::Ident, "def"}, + CSSToken{CSSTokenType::String, ""}, + CSSToken{CSSTokenType::EndOfFile}); +} + +// https://www.w3.org/TR/css-syntax-3/#consume-url-token +TEST(CSSTokenizer, url_values) { + EXPECT_TOKENS( + "url(a.png)", + CSSToken{CSSTokenType::Url, "a.png"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url(http://localhost:8081/1.png)", + CSSToken{CSSTokenType::Url, "http://localhost:8081/1.png"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url( a.png )", + CSSToken{CSSTokenType::Url, "a.png"}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url()", + CSSToken{CSSTokenType::Url, ""}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "URL(a.png)", + CSSToken{CSSTokenType::Url, "a.png"}, + CSSToken{CSSTokenType::EndOfFile}); + + // A quoted body stays a function block holding a . + EXPECT_TOKENS( + "url(\"a.png\")", + CSSToken{CSSTokenType::Function, "url"}, + CSSToken{CSSTokenType::String, "a.png"}, + CSSToken{CSSTokenType::CloseParen}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url( 'a.png')", + CSSToken{CSSTokenType::Function, "url"}, + CSSToken{CSSTokenType::String, "a.png"}, + CSSToken{CSSTokenType::CloseParen}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url(a.png", + CSSToken{CSSTokenType::Url, "a.png"}, + CSSToken{CSSTokenType::EndOfFile}); +} + +TEST(CSSTokenizer, bad_url_values) { + EXPECT_TOKENS( + "url(my file.png)", + CSSToken{CSSTokenType::BadUrl}, + CSSToken{CSSTokenType::EndOfFile}); + + // Nor is an unescaped paren or quote. Recovery stops at the first `)`, which + // here is the inner one, so the remainder tokenizes as ordinary values. + EXPECT_TOKENS( + "url(a(1).png)", + CSSToken{CSSTokenType::BadUrl}, + CSSToken{CSSTokenType::Delim, "."}, + CSSToken{CSSTokenType::Ident, "png"}, + CSSToken{CSSTokenType::CloseParen}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url(a\"b.png)", + CSSToken{CSSTokenType::BadUrl}, + CSSToken{CSSTokenType::EndOfFile}); + + EXPECT_TOKENS( + "url(my file.png) auto", + CSSToken{CSSTokenType::BadUrl}, + CSSToken{CSSTokenType::WhiteSpace}, + CSSToken{CSSTokenType::Ident, "auto"}, + CSSToken{CSSTokenType::EndOfFile}); +} } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp index 9ee16ac87392..d4ffffd4b9af 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp @@ -10,11 +10,20 @@ namespace facebook::react { #ifdef RN_SERIALIZABLE_STATE +folly::dynamic URLBackgroundImage::toDynamic() const { + folly::dynamic result = folly::dynamic::object(); + result["type"] = "url"; + result["uri"] = uri; + return result; +} + folly::dynamic toDynamic(const BackgroundImage& backgroundImage) { if (std::holds_alternative(backgroundImage)) { return std::get(backgroundImage).toDynamic(); } else if (std::holds_alternative(backgroundImage)) { return std::get(backgroundImage).toDynamic(); + } else if (std::holds_alternative(backgroundImage)) { + return std::get(backgroundImage).toDynamic(); } return folly::dynamic(nullptr); } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h index ab0c62e506ca..6dc598f357a0 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h @@ -9,12 +9,45 @@ #include +#include + #include #include namespace facebook::react { -using BackgroundImage = std::variant; +class ImageSource; + +struct URLBackgroundImage { + std::string uri{}; + // Natural size in layout units, supplied by the asset registry. Zero when the + // source is a plain url, in which case the decoded image's size is used. + Float intrinsicWidth{0}; + Float intrinsicHeight{0}; + + bool operator==(const URLBackgroundImage &rhs) const + { + return uri == rhs.uri && intrinsicWidth == rhs.intrinsicWidth && intrinsicHeight == rhs.intrinsicHeight; + } + + bool operator!=(const URLBackgroundImage &rhs) const + { + return !(*this == rhs); + } + +#ifdef RN_SERIALIZABLE_STATE + folly::dynamic toDynamic() const; +#endif + +#if RN_DEBUG_STRING_CONVERTIBLE + void toString(std::stringstream &ss) const + { + ss << "url(" << uri << ")"; + } +#endif +}; + +using BackgroundImage = std::variant; #ifdef RN_SERIALIZABLE_STATE folly::dynamic toDynamic(const BackgroundImage &backgroundImage); @@ -36,6 +69,8 @@ inline std::string toString(std::vector &value) std::get(backgroundImage).toString(ss); } else if (std::holds_alternative(backgroundImage)) { std::get(backgroundImage).toString(ss); + } else if (std::holds_alternative(backgroundImage)) { + std::get(backgroundImage).toString(ss); } } ss << "]"; diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..725e574fb7ab 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<95d2bad1aa94a3efd4afa1667a0571d0>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1585,7 +1585,8 @@ declare type AttributeType = readonly process?: (arg1: V) => T } declare type AutoCapitalize = "characters" | "none" | "sentences" | "words" -declare type BackgroundImageValue = LinearGradientValue | RadialGradientValue +declare type BackgroundImageValue = + GradientBackgroundImageValue | URLBackgroundImageValue declare type BackgroundPositionValue = | { bottom: number | string @@ -2356,6 +2357,8 @@ declare function getWithFallback_DEPRECATED( name: string, viewConfigProvider: () => PartialViewConfig, ): React.ComponentType +declare type GradientBackgroundImageValue = + LinearGradientValue | RadialGradientValue declare type hairlineWidth = typeof hairlineWidth declare interface HardwareBackPressEvent { readonly timeStamp: number @@ -5436,6 +5439,10 @@ declare type URIRequestBody = { readonly string?: string readonly uri: string } +declare type URLBackgroundImageValue = { + type: "url" + uri: number | string +} declare function useAnimatedColor( inputValue?: InputValue, config?: AnimatedColorConfig | null | undefined, @@ -5782,18 +5789,18 @@ export { AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // d5bcb272 + ActivityIndicator, // a4cfe6fa ActivityIndicatorInstance, // 1b5e0dd5 - ActivityIndicatorProps, // 508a24c6 + ActivityIndicatorProps, // 9f8427d0 Alert, // a398a509 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 33fe7464 + Animated, // 179682f1 AppConfig, // 35c0ca70 - AppRegistry, // edec2a30 + AppRegistry, // e0767bbc AppState, // 12012be5 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -5805,8 +5812,8 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // d5e68e96 BoxShadowValue, // b679703f - Button, // f253fc94 - ButtonInstance, // bcf2ef72 + Button, // a46d0637 + ButtonInstance, // 77cb31b6 ButtonProps, // 9886bcc6 Clipboard, // 41addb89 CodegenTypes, // 9067fd06 @@ -5825,9 +5832,9 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // beec8621 + DrawerLayoutAndroid, // 503e4339 DrawerLayoutAndroidInstance, // aa243cbf - DrawerLayoutAndroidProps, // 53289373 + DrawerLayoutAndroidProps, // 96c5df99 DrawerSlideEvent, // 38213685 DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5843,9 +5850,9 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // 5ba109bd - FlatListInstance, // 1095fe06 - FlatListProps, // dd43b1d1 + FlatList, // eec7135f + FlatListInstance, // 9e634184 + FlatListProps, // 33a63ace FocusEvent, // f2e4363d FontVariant, // 7c7558bb GestureResponderEvent, // 5cd2a663 @@ -5856,17 +5863,17 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // a24a5777 - ImageBackground, // 1fbbacef - ImageBackgroundInstance, // 9c900a60 - ImageBackgroundProps, // b078f0ac + Image, // 354531d1 + ImageBackground, // 5aa7743c + ImageBackgroundInstance, // 61957968 + ImageBackgroundProps, // e1d3c37b ImageErrorEvent, // b6227714 ImageInstance, // df709524 ImageLoadEvent, // 31709c38 ImageProgressEventIOS, // 9243d460 - ImageProps, // e6a7a863 + ImageProps, // a6134217 ImagePropsAndroid, // ee00e1d5 - ImagePropsBase, // f41b5a7c + ImagePropsBase, // dff40c63 ImagePropsIOS, // 1998f91a ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 @@ -5874,19 +5881,19 @@ export { ImageSize, // 1c47cf88 ImageSource, // ea31cf4a ImageSourcePropType, // f522e093 - ImageStyle, // 85dc694a + ImageStyle, // 4367a619 ImageURISource, // 443d047c - InputAccessoryView, // ab6e2a66 - InputAccessoryViewProps, // 73685b83 + InputAccessoryView, // c4b7cab1 + InputAccessoryViewProps, // 6fe8615a InputModeOptions, // 4e8581b9 Insets, // e7fe432a KeyDownEvent, // a12d46ea KeyEvent, // 20fa4267 KeyUpEvent, // 0b5d0144 Keyboard, // 49414c97 - KeyboardAvoidingView, // 1fee106e - KeyboardAvoidingViewInstance, // 26062fd4 - KeyboardAvoidingViewProps, // 635ae7fc + KeyboardAvoidingView, // f306b578 + KeyboardAvoidingViewInstance, // 2b1296e3 + KeyboardAvoidingViewProps, // acd6df64 KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -5911,10 +5918,10 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // f4b88be5 + Modal, // a8467aba ModalBaseProps, // a6e4334f ModalInstance, // 0f04c1bf - ModalProps, // c07a247a + ModalProps, // 6872e3d8 ModalPropsAndroid, // 515fb173 ModalPropsIOS, // a65a8f5c ModeChangeEvent, // ccf30dc4 @@ -5950,15 +5957,15 @@ export { PointerEvent, // a506d9d7 PressabilityConfig, // 0fd655fe PressabilityEventHandlers, // be359ba3 - Pressable, // 56235a5f + Pressable, // cdcb9733 PressableAndroidRippleConfig, // ee32eaca PressableInstance, // ed26f040 - PressableProps, // 7a18f8cd + PressableProps, // b3ecb90c PressableStateCallbackType, // f83728cd ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // 30b4a1eb + ProgressBarAndroid, // 474cf450 ProgressBarAndroidInstance, // f17242be - ProgressBarAndroidProps, // 98fa6a86 + ProgressBarAndroidProps, // 4b9267fe PublicRootInstance, // 8040afd7 PublicTextInstance, // f9adf69a PushNotificationEventName, // 84e7e150 @@ -5966,9 +5973,9 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // e74ab0c4 - RefreshControlInstance, // 7e1aa88d - RefreshControlProps, // e728e273 + RefreshControl, // 2df6eebc + RefreshControlInstance, // 21783645 + RefreshControlProps, // 629ece48 RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d @@ -5977,28 +5984,28 @@ export { Role, // af7b889d RootTag, // 3cd10504 RootTagContext, // 38bfc8f6 - RootViewStyleProvider, // d5909148 + RootViewStyleProvider, // 63fa2305 Runnable, // 594dd93a Runnables, // 4367c557 SafeAreaInsetsChangeEvent, // cedb5309 - SafeAreaView, // dda99293 + SafeAreaView, // 22e242f0 SafeAreaViewInstance, // 2b5d4391 ScaledSize, // 07e417c7 ScrollEvent, // 956fdb27 - ScrollResponderType, // 52c38666 + ScrollResponderType, // 205eaaf9 ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 08fe7620 - ScrollViewImperativeMethods, // 682315af - ScrollViewInstance, // bcc836b9 - ScrollViewProps, // d845721f + ScrollView, // 623f0163 + ScrollViewImperativeMethods, // 171241c8 + ScrollViewInstance, // e9e452f5 + ScrollViewProps, // 08501dc1 ScrollViewPropsAndroid, // 02f3df2e ScrollViewPropsIOS, // c378379b ScrollViewScrollToOptions, // 3313411e SectionBase, // 9f13db00 - SectionList, // b48c3713 + SectionList, // f1026786 SectionListData, // 1a4de01a - SectionListInstance, // e2814c16 - SectionListProps, // bf7d4c45 + SectionListInstance, // ae9ef11b + SectionListProps, // 0ed5877c SectionListRenderItem, // 715b2086 SectionListRenderItemInfo, // 4a48a922 Separators, // 6a45f7e3 @@ -6015,19 +6022,19 @@ export { StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // 8bb13f55 + StyleSheet, // 99103509 SubmitBehavior, // c4ddf490 - Switch, // 7ea8a527 + Switch, // 478941a9 SwitchChangeEvent, // 3b472666 SwitchInstance, // 73dce99f - SwitchProps, // aa00dcf4 + SwitchProps, // 29ae4b34 Systrace, // 626d178c TVViewPropsIOS, // 1c612e4e TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 935ec305 + Text, // 95167c21 TextContentType, // 239b3ecc - TextInput, // cf6467dc + TextInput, // 4e4f8d85 TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b4c7a58c TextInputChangeEvent, // 92363e10 @@ -6037,46 +6044,46 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 53fe7b73 TextInputKeyPressEvent, // cbbc322a - TextInputProps, // bb087c92 + TextInputProps, // 1bd63490 TextInputSelectionChangeEvent, // 4a8f10c2 TextInputSubmitEditingEvent, // e811fdbd TextInstance, // 7c7e2069 TextLayoutEvent, // 1a21e24a - TextProps, // b3707dfc - TextStyle, // 898adfd1 + TextProps, // e988b34e + TextStyle, // 39a48bdc ToastAndroid, // 88a8969a - TouchableHighlight, // a015710f + TouchableHighlight, // 8f67d8d8 TouchableHighlightInstance, // 811af7ca - TouchableHighlightProps, // faaeb188 - TouchableNativeFeedback, // 744ee0ee - TouchableNativeFeedbackInstance, // b90e3bae - TouchableNativeFeedbackProps, // 39abde16 - TouchableOpacity, // 6aec357f + TouchableHighlightProps, // a8f65339 + TouchableNativeFeedback, // 7e8ee859 + TouchableNativeFeedbackInstance, // 6e557698 + TouchableNativeFeedbackProps, // 75bcc9dc + TouchableOpacity, // cc0e39ce TouchableOpacityInstance, // 0cd8a347 - TouchableOpacityProps, // 0d78e636 - TouchableWithoutFeedback, // 92cfc35d - TouchableWithoutFeedbackProps, // 7e8a325c + TouchableOpacityProps, // 7e31b67b + TouchableWithoutFeedback, // 7426f441 + TouchableWithoutFeedbackProps, // 1cbda744 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 UIManager, // 6a18fc05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 85575778 + View, // 6663cafa ViewInstance, // 0283d2fc - ViewProps, // 43cbabe5 + ViewProps, // f53e2945 ViewPropsAndroid, // 66d760c6 ViewPropsIOS, // 58ee19bf - ViewStyle, // 4e2746d6 + ViewStyle, // a2b11ecc ViewabilityConfig, // eb3f7ed8 ViewabilityConfigCallbackPair, // bdd0788c VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e VirtualizedListInstance, // 423ee7c0 - VirtualizedListProps, // 09a41c15 + VirtualizedListProps, // 5c5e38ac VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // 12b706d5 - VirtualizedSectionListProps, // c63aa8a2 + VirtualizedSectionListProps, // d634af43 WrapperComponentProvider, // 9b4247f6 codegenNativeCommands, // 322f3f4e codegenNativeComponent, // 3da62daa diff --git a/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts index 478e9d092195..61c26118f91f 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -407,7 +407,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; diff --git a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js index 949369ed4137..325649fcaf42 100644 --- a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js +++ b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js @@ -441,4 +441,44 @@ exports.examples = [ ); }, }, + { + title: 'URL Image', + name: 'url-image', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'URL Image from local file', + name: 'local-image', + render(): React.Node { + return ( + + ); + }, + }, ] as Array; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index ca72d3985fc9..29d402cd297a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -391,7 +391,7 @@ using facebook::react::AndroidDrawerLayoutState = facebook::react::StateData; using facebook::react::AndroidSwipeRefreshLayoutComponentDescriptor = facebook::react::ConcreteComponentDescriptor; using facebook::react::AndroidSwipeRefreshLayoutShadowNode = facebook::react::ConcreteViewShadowNode; using facebook::react::AndroidSwipeRefreshLayoutState = facebook::react::StateData; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -3660,6 +3660,15 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public folly::dynamic toDynamic() const; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 5e5dad5f5ce5..e2f2ccd3b0d9 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -482,14 +482,14 @@ using facebook::react::AndroidSwipeRefreshLayoutState = facebook::react::StateDa using facebook::react::AnimationEndCallback = facebook::react::AsyncCallback; using facebook::react::AnimationTimestamp = std::chrono::duration; using facebook::react::BackgroundExecutor = std::function&& callback)>; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; using facebook::react::BorderRadii = facebook::react::RectangleCorners; using facebook::react::BorderStyles = facebook::react::RectangleEdges; using facebook::react::BorderWidths = facebook::react::RectangleEdges; -using facebook::react::CSSBackgroundImage = facebook::react::CSSCompoundDataType; +using facebook::react::CSSBackgroundImage = facebook::react::CSSCompoundDataType; using facebook::react::CSSBackgroundImageList = facebook::react::CSSCommaSeparatedList; using facebook::react::CSSFilterFunction = facebook::react::CSSCompoundDataType; using facebook::react::CSSFilterList = facebook::react::CSSWhitespaceSeparatedList; @@ -4868,6 +4868,8 @@ enum facebook::react::CSSRadialGradientSizeKeyword : uint8_t { } enum facebook::react::CSSTokenType { + BadString, + BadUrl, CloseCurly, CloseParen, CloseSquare, @@ -4883,6 +4885,8 @@ enum facebook::react::CSSTokenType { OpenParen, OpenSquare, Percentage, + String, + Url, WhiteSpace, } @@ -5979,6 +5983,11 @@ struct facebook::react::CSSTranslateY { public std::variant value; } +struct facebook::react::CSSURLFunction { + public bool operator==(const facebook::react::CSSURLFunction& rhs) const = default; + public std::string url; +} + struct facebook::react::CSSZero { public constexpr bool operator==(const facebook::react::CSSZero& rhs) const = default; } @@ -6703,6 +6712,15 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public folly::dynamic toDynamic() const; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); @@ -8269,6 +8287,10 @@ struct facebook::react::CSSDataTypeParser : publ struct facebook::react::CSSDataTypeParser : public facebook::react::detail::CSSVariantComponentTransformParser { } +struct facebook::react::CSSDataTypeParser { + public static std::optional consumeFunctionBlock(const facebook::react::CSSFunctionBlock& func, facebook::react::CSSValueParser& parser); +} + struct facebook::react::CSSDataTypeParser { public static constexpr std::optional consumePreservedToken(const facebook::react::CSSPreservedToken& token); } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 1a105ab8080c..c0e47c5fa3e3 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -391,7 +391,7 @@ using facebook::react::AndroidDrawerLayoutState = facebook::react::StateData; using facebook::react::AndroidSwipeRefreshLayoutComponentDescriptor = facebook::react::ConcreteComponentDescriptor; using facebook::react::AndroidSwipeRefreshLayoutShadowNode = facebook::react::ConcreteViewShadowNode; using facebook::react::AndroidSwipeRefreshLayoutState = facebook::react::StateData; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -3653,6 +3653,15 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public folly::dynamic toDynamic() const; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 1a105ab8080c..c0e47c5fa3e3 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -391,7 +391,7 @@ using facebook::react::AndroidDrawerLayoutState = facebook::react::StateData; using facebook::react::AndroidSwipeRefreshLayoutComponentDescriptor = facebook::react::ConcreteComponentDescriptor; using facebook::react::AndroidSwipeRefreshLayoutShadowNode = facebook::react::ConcreteViewShadowNode; using facebook::react::AndroidSwipeRefreshLayoutState = facebook::react::StateData; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -3653,6 +3653,15 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public folly::dynamic toDynamic() const; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index e04f3b738889..237dc762996b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -3133,7 +3133,7 @@ using facebook::react::ModuleConstants = _RCTTypedModuleConstants; using facebook::react::ActivityIndicatorViewShadowNode = facebook::react::ConcreteViewShadowNode; using facebook::react::ActivityIndicatorViewState = facebook::react::StateData; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -5629,6 +5629,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index cb8ca685d4f2..61f467e02732 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -3263,14 +3263,14 @@ using facebook::react::ActivityIndicatorViewState = facebook::react::StateData; using facebook::react::AnimationEndCallback = facebook::react::AsyncCallback; using facebook::react::AnimationTimestamp = std::chrono::duration; using facebook::react::BackgroundExecutor = std::function&& callback)>; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; using facebook::react::BorderRadii = facebook::react::RectangleCorners; using facebook::react::BorderStyles = facebook::react::RectangleEdges; using facebook::react::BorderWidths = facebook::react::RectangleEdges; -using facebook::react::CSSBackgroundImage = facebook::react::CSSCompoundDataType; +using facebook::react::CSSBackgroundImage = facebook::react::CSSCompoundDataType; using facebook::react::CSSBackgroundImageList = facebook::react::CSSCommaSeparatedList; using facebook::react::CSSFilterFunction = facebook::react::CSSCompoundDataType; using facebook::react::CSSFilterList = facebook::react::CSSWhitespaceSeparatedList; @@ -6997,6 +6997,8 @@ enum facebook::react::CSSRadialGradientSizeKeyword : uint8_t { } enum facebook::react::CSSTokenType { + BadString, + BadUrl, CloseCurly, CloseParen, CloseSquare, @@ -7012,6 +7014,8 @@ enum facebook::react::CSSTokenType { OpenParen, OpenSquare, Percentage, + String, + Url, WhiteSpace, } @@ -8130,6 +8134,11 @@ struct facebook::react::CSSTranslateY { public std::variant value; } +struct facebook::react::CSSURLFunction { + public bool operator==(const facebook::react::CSSURLFunction& rhs) const = default; + public std::string url; +} + struct facebook::react::CSSZero { public constexpr bool operator==(const facebook::react::CSSZero& rhs) const = default; } @@ -8693,6 +8702,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); @@ -10148,6 +10165,10 @@ struct facebook::react::CSSDataTypeParser : publ struct facebook::react::CSSDataTypeParser : public facebook::react::detail::CSSVariantComponentTransformParser { } +struct facebook::react::CSSDataTypeParser { + public static std::optional consumeFunctionBlock(const facebook::react::CSSFunctionBlock& func, facebook::react::CSSValueParser& parser); +} + struct facebook::react::CSSDataTypeParser { public static constexpr std::optional consumePreservedToken(const facebook::react::CSSPreservedToken& token); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 9010fcf23951..22bf9fb4d9a6 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -3126,7 +3126,7 @@ using facebook::react::ModuleConstants = _RCTTypedModuleConstants; using facebook::react::ActivityIndicatorViewShadowNode = facebook::react::ConcreteViewShadowNode; using facebook::react::ActivityIndicatorViewState = facebook::react::StateData; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -5615,6 +5615,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 6bb9397bd2e4..8d95e82eb9de 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -3133,7 +3133,7 @@ using facebook::react::ModuleConstants = _RCTTypedModuleConstants; using facebook::react::ActivityIndicatorViewShadowNode = facebook::react::ConcreteViewShadowNode; using facebook::react::ActivityIndicatorViewState = facebook::react::StateData; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -5622,6 +5622,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 507e71d72698..bcf6c74b6106 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -80,7 +80,7 @@ static const facebook::react::LayoutMetrics facebook::react::EmptyLayoutMetrics; static const facebook::react::TelemetryTimePoint facebook::react::kTelemetryUndefinedTimePoint; static constexpr auto facebook::react::kPropNameLengthHardCap; static constexpr facebook::react::RawPropsValueIndex facebook::react::kRawPropsValueIndexEmpty; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -2071,6 +2071,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index cb97eccc7f11..232322a2cd26 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -110,14 +110,14 @@ using facebook::react::ActivePointerRegistry = std::unordered_map; using facebook::react::AnimationTimestamp = std::chrono::duration; using facebook::react::BackgroundExecutor = std::function&& callback)>; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; using facebook::react::BorderRadii = facebook::react::RectangleCorners; using facebook::react::BorderStyles = facebook::react::RectangleEdges; using facebook::react::BorderWidths = facebook::react::RectangleEdges; -using facebook::react::CSSBackgroundImage = facebook::react::CSSCompoundDataType; +using facebook::react::CSSBackgroundImage = facebook::react::CSSCompoundDataType; using facebook::react::CSSBackgroundImageList = facebook::react::CSSCommaSeparatedList; using facebook::react::CSSFilterFunction = facebook::react::CSSCompoundDataType; using facebook::react::CSSFilterList = facebook::react::CSSWhitespaceSeparatedList; @@ -3279,6 +3279,8 @@ enum facebook::react::CSSRadialGradientSizeKeyword : uint8_t { } enum facebook::react::CSSTokenType { + BadString, + BadUrl, CloseCurly, CloseParen, CloseSquare, @@ -3294,6 +3296,8 @@ enum facebook::react::CSSTokenType { OpenParen, OpenSquare, Percentage, + String, + Url, WhiteSpace, } @@ -4340,6 +4344,11 @@ struct facebook::react::CSSTranslateY { public std::variant value; } +struct facebook::react::CSSURLFunction { + public bool operator==(const facebook::react::CSSURLFunction& rhs) const = default; + public std::string url; +} + struct facebook::react::CSSZero { public constexpr bool operator==(const facebook::react::CSSZero& rhs) const = default; } @@ -4869,6 +4878,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public Float intrinsicHeight; + public Float intrinsicWidth; + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); @@ -5505,6 +5522,10 @@ struct facebook::react::CSSDataTypeParser : publ struct facebook::react::CSSDataTypeParser : public facebook::react::detail::CSSVariantComponentTransformParser { } +struct facebook::react::CSSDataTypeParser { + public static std::optional consumeFunctionBlock(const facebook::react::CSSFunctionBlock& func, facebook::react::CSSValueParser& parser); +} + struct facebook::react::CSSDataTypeParser { public static constexpr std::optional consumePreservedToken(const facebook::react::CSSPreservedToken& token); } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 004abd9081df..b432024568c2 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -80,7 +80,7 @@ static const facebook::react::LayoutMetrics facebook::react::EmptyLayoutMetrics; static const facebook::react::TelemetryTimePoint facebook::react::kTelemetryUndefinedTimePoint; static constexpr auto facebook::react::kPropNameLengthHardCap; static constexpr facebook::react::RawPropsValueIndex facebook::react::kRawPropsValueIndexEmpty; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -2064,6 +2064,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 004abd9081df..b432024568c2 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -80,7 +80,7 @@ static const facebook::react::LayoutMetrics facebook::react::EmptyLayoutMetrics; static const facebook::react::TelemetryTimePoint facebook::react::kTelemetryUndefinedTimePoint; static constexpr auto facebook::react::kPropNameLengthHardCap; static constexpr facebook::react::RawPropsValueIndex facebook::react::kRawPropsValueIndexEmpty; -using facebook::react::BackgroundImage = std::variant; +using facebook::react::BackgroundImage = std::variant; using facebook::react::BackgroundSize = std::variant; using facebook::react::BorderColors = facebook::react::RectangleEdges; using facebook::react::BorderCurves = facebook::react::RectangleCorners; @@ -2064,6 +2064,14 @@ struct facebook::react::TransformOrigin { public std::array xy; } +struct facebook::react::URLBackgroundImage { + public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; + public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; + public facebook::react::Float intrinsicHeight; + public facebook::react::Float intrinsicWidth; + public std::string uri; +} + struct facebook::react::ValueUnit { public constexpr ValueUnit() = default; public constexpr ValueUnit(float v, facebook::react::UnitType u); From f79044cb39595e30c4604762796c05c743445775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 14:54:24 +0530 Subject: [PATCH 2/8] Send the intrinsic size of url() background images in diff props URLBackgroundImage::toDynamic() wrote only the type and uri, so with enablePropsUpdateReconciliationAndroid a require() asset lost its intrinsic size and Android drew it at its decoded pixel size. --- .../components/view/tests/ViewPropsDiffTest.cpp | 10 ++++++++++ .../react/renderer/graphics/BackgroundImage.cpp | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp index 7fe5a6dfdab3..d14e7b5b6e1c 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp @@ -103,6 +103,16 @@ TEST(ViewPropsDiffTest, gradientColorHintEmitsNullColor) { "position", nullptr)))))}}); } +// `require()` assets send their natural size with the uri. +TEST(ViewPropsDiffTest, urlBackgroundImageKeepsIntrinsicSize) { + expectPayloads({{dynamic::object( + "backgroundImage", + dynamic::array( + dynamic::object("type", "url")("uri", "https://example.com/a.png"), + dynamic::object("type", "url")("uri", "asset.png")( + "intrinsicWidth", 100)("intrinsicHeight", 50)))}}); +} + // BaseViewManager.setViewState replaces the whole state: an absent `selected` // deselects the view and an absent `disabled` re-enables it. Props 2.0 sends an // unset `checked` as "none", which Java ignores. diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp index d4ffffd4b9af..dacf854e950e 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp @@ -14,6 +14,12 @@ folly::dynamic URLBackgroundImage::toDynamic() const { folly::dynamic result = folly::dynamic::object(); result["type"] = "url"; result["uri"] = uri; + if (intrinsicWidth > 0) { + result["intrinsicWidth"] = intrinsicWidth; + } + if (intrinsicHeight > 0) { + result["intrinsicHeight"] = intrinsicHeight; + } return result; } From 1fb50249645874cf25317835683cd3c02edfc90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 14:54:24 +0530 Subject: [PATCH 3/8] Resolve require() background images with native CSS parsing With enableNativeCSSParsing, backgroundImage was sent unprocessed, and native code cannot resolve an asset id, so require() url layers were dropped. Resolve only those in JS and leave the rest to native parsing. --- .../View/ReactNativeStyleAttributes.js | 6 +- .../Components/View/__tests__/View-itest.js | 25 +++++++ .../__tests__/View-nativeCSSParsing-itest.js | 18 +++++ .../StyleSheet/processBackgroundImage.js | 72 +++++++++++-------- 4 files changed, 90 insertions(+), 31 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index e869e9fa41d5..e9e7c73821a1 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -12,7 +12,9 @@ import type {AnyAttributeType} from '../../Renderer/shims/ReactNativeTypes'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import processAspectRatio from '../../StyleSheet/processAspectRatio'; -import processBackgroundImage from '../../StyleSheet/processBackgroundImage'; +import processBackgroundImage, { + processBackgroundImageAssets, +} from '../../StyleSheet/processBackgroundImage'; import processBackgroundPosition from '../../StyleSheet/processBackgroundPosition'; import processBackgroundRepeat from '../../StyleSheet/processBackgroundRepeat'; import processBackgroundSize from '../../StyleSheet/processBackgroundSize'; @@ -45,7 +47,7 @@ export const boxShadowAttribute: AnyAttributeType = nativeCSSParsing : {process: processBoxShadow}; export const backgroundImageAttribute: AnyAttributeType = nativeCSSParsing - ? true + ? {process: processBackgroundImageAssets} : {process: processBackgroundImage}; export const backgroundSizeAttribute: AnyAttributeType = nativeCSSParsing diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js index a26f29766aed..bf9148cdf2f0 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js @@ -353,6 +353,31 @@ describe('', () => { }); describe('background-image', () => { + it('resolves a require() asset in the object syntax', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect( + root.getRenderedOutput({props: ['backgroundImage']}).toJSX(), + ).toEqual( + , + ); + }); + it('it parses CSS and object syntax', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js index d82df171b772..4c83424cb430 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js @@ -71,5 +71,23 @@ describe(' native CSS parsing', () => { expect(backgroundImage).toContain('linear-gradient'); expect(backgroundImage).toContain('rgba(230, 100, 101, 1)'); }); + + it('resolves a require() asset in the object syntax', () => { + expect( + mountedProp( + { + backgroundImage: [ + { + type: 'url', + uri: require('../../../Image/__tests__/img/img1.png'), + }, + ], + }, + 'backgroundImage', + ), + ).toBe( + '[url(file://drawable-mdpi/packages_reactnative_libraries_image___tests___img_img1.png)]', + ); + }); }); }); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 4f471265d309..78c5e71a55a6 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -92,6 +92,45 @@ type ParsedBackgroundImageValue = | RadialGradientBackgroundImage | URLBackgroundImage; +/** + * With native CSS parsing, native code parses `backgroundImage` but cannot + * resolve a `require()` asset, so this resolves only those. + */ +export function processBackgroundImageAssets( + backgroundImage: ?(ReadonlyArray | string), +): ?(ReadonlyArray | string) { + if (!Array.isArray(backgroundImage)) { + return backgroundImage; + } + return backgroundImage.map(bgImage => + bgImage.type === 'url' && typeof bgImage.uri === 'number' + ? (processURLBackgroundImage(bgImage.uri) ?? bgImage) + : bgImage, + ); +} + +function processURLBackgroundImage(uri: number | string): ?URLBackgroundImage { + if (typeof uri === 'string') { + return {type: 'url', uri}; + } + const source = resolveAssetSource(uri); + if (source == null || source.uri == null) { + return null; + } + // The asset registry records these already divided by the asset's scale, so + // they are layout units and need no density maths native side. Matches how + // `Image` derives its default dimensions. The registry reports both + // dimensions or neither. + return typeof source.width === 'number' && typeof source.height === 'number' + ? { + type: 'url', + uri: source.uri, + intrinsicWidth: source.width, + intrinsicHeight: source.height, + } + : {type: 'url', uri: source.uri}; +} + export default function processBackgroundImage( backgroundImage: ?(ReadonlyArray | string), ): ReadonlyArray { @@ -107,38 +146,13 @@ export default function processBackgroundImage( } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { if (bgImage.type === 'url') { - let uri: ?string = null; - let intrinsicWidth: ?number = null; - let intrinsicHeight: ?number = 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; - // The asset registry records these already divided by the asset's - // scale, so they are layout units and need no density maths native - // side. Matches how `Image` derives its default dimensions. - if (typeof source.width === 'number') { - intrinsicWidth = source.width; - } - if (typeof source.height === 'number') { - intrinsicHeight = source.height; - } - } - } - if (uri != null) { - // The registry reports both dimensions or neither. - result = result.concat( - intrinsicWidth != null && intrinsicHeight != null - ? {type: 'url', uri, intrinsicWidth, intrinsicHeight} - : {type: 'url', uri}, - ); - continue; - } else { + const urlBackgroundImage = processURLBackgroundImage(bgImage.uri); + if (urlBackgroundImage == null) { // If the URI is invalid, return an empty array. Same as web. return []; } + result = result.concat(urlBackgroundImage); + continue; } const processedColorStops = processColorStops(bgImage); From 12a4c9016cdf05e147e5453bcf56f2e9be2a04a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 15:25:53 +0530 Subject: [PATCH 4/8] Parse url() in background images as the CSS spec does - The JS parser follows the CSS Syntax url token rules instead of a loose regex: an unquoted url cannot contain whitespace, quotes or parentheses, a quoted url must be a single string, and escapes are decoded. This matches the native parser, so a style no longer works only with one setting of enableNativeCSSParsing. - An empty uri in the object syntax is rejected in JS and in native code. The native url conversion is shared by both parsing paths. - Remove an unused forward declaration from BackgroundImage.h. - Add tests for urls with parentheses, data uris and invalid urls. --- .../__tests__/View-nativeCSSParsing-itest.js | 9 ++ .../__tests__/processBackgroundImage-itest.js | 65 ++++++++ .../StyleSheet/processBackgroundImage.js | 146 ++++++++++++++++-- .../view/BackgroundImagePropsConversions.cpp | 64 ++++---- .../react/renderer/graphics/BackgroundImage.h | 2 - 5 files changed, 242 insertions(+), 44 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js index 4c83424cb430..63688483c692 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js @@ -72,6 +72,15 @@ describe(' native CSS parsing', () => { expect(backgroundImage).toContain('rgba(230, 100, 101, 1)'); }); + it('drops a url layer with an empty uri', () => { + expect( + mountedProp( + {backgroundImage: [{type: 'url', uri: ''}]}, + 'backgroundImage', + ), + ).toBeUndefined(); + }); + it('resolves a require() asset in the object syntax', () => { expect( mountedProp( diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 7eded27e1733..5364f8619593 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js @@ -1308,4 +1308,69 @@ describe('processBackgroundImage', () => { const result = processBackgroundImage('url("")'); expect(result).toEqual([]); }); + + it('should return empty for an empty uri in the object syntax', () => { + const result = processBackgroundImage([{type: 'url', uri: ''}]); + expect(result).toEqual([]); + }); + + it('should keep parentheses inside a quoted url', () => { + // Parentheses are valid unencoded in urls, and CSS requires quotes for them. + const result = processBackgroundImage( + 'url("https://upload.wikimedia.org/wikipedia/commons/6/69/Surface_of_Mercury_(14433689302).jpg"), linear-gradient(red, blue)', + ); + expect(result).toEqual([ + { + type: 'url', + uri: 'https://upload.wikimedia.org/wikipedia/commons/6/69/Surface_of_Mercury_(14433689302).jpg', + }, + { + type: 'linear-gradient', + direction: {type: 'angle', value: 180}, + colorStops: [ + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, + ], + }, + ]); + }); + + it('should keep the comma in a quoted data uri', () => { + const result = processBackgroundImage( + 'url("data:image/png;base64,iVBORw0KGgo="), url(https://example.com/a.png)', + ); + expect(result).toEqual([ + {type: 'url', uri: 'data:image/png;base64,iVBORw0KGgo='}, + {type: 'url', uri: 'https://example.com/a.png'}, + ]); + }); + + // https://www.w3.org/TR/css-syntax-3/#consume-url-token + for (const [name, backgroundImage] of [ + ['whitespace inside an unquoted url', 'url(my photo.png)'], + [ + 'parentheses inside an unquoted url', + 'url(https://upload.wikimedia.org/wikipedia/commons/6/69/Surface_of_Mercury_(14433689302).jpg)', + ], + ['a quote inside an unquoted url', 'url(a"b.png)'], + ['text after a quoted url', 'url("a.png" x)'], + ['mismatched quotes', 'url("a.png\')'], + ]) { + it(`should return empty for ${name}`, () => { + expect(processBackgroundImage(backgroundImage)).toEqual([]); + }); + } + + for (const [name, backgroundImage, uri] of [ + ['an escaped parenthesis', 'url(a\\)b.png)', 'a)b.png'], + ['an escaped quote in a quoted url', 'url("a\\"b.png")', 'a"b.png'], + ['a hex escape', 'url(a\\20 b.png)', 'a b.png'], + ['a missing closing parenthesis', 'url(a.png', 'a.png'], + ]) { + it(`should parse a url with ${name}`, () => { + expect(processBackgroundImage(backgroundImage)).toEqual([ + {type: 'url', uri}, + ]); + }); + } }); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 78c5e71a55a6..f52f045a5c83 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -25,7 +25,7 @@ const processColor = require('./processColor').default; // Pre-compiled regex patterns for performance - avoids regex compilation on each call const NEWLINE_REGEX = /\n/g; const GRADIENT_REGEX = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/; -const URL_REGEX = /^url\((.*)\)$/i; +const URL_FUNCTION_REGEX = /^url\(/i; const COMMA_SPLIT_REGEX = /,(?![^(]*\))/; const WHITESPACE_SPLIT_REGEX = /\s+/; const COLOR_STOP_PARTS_REGEX = /\S+\([^)]*\)|\S+/g; @@ -111,7 +111,7 @@ export function processBackgroundImageAssets( function processURLBackgroundImage(uri: number | string): ?URLBackgroundImage { if (typeof uri === 'string') { - return {type: 'url', uri}; + return uri.length > 0 ? {type: 'url', uri} : null; } const source = resolveAssetSource(uri); if (source == null || source.uri == null) { @@ -327,14 +327,9 @@ function parseBackgroundImageCSSString( const bgImageStrings = splitGradients(cssString); for (const bgImageString of bgImageStrings) { - const urlMatch = URL_REGEX.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) { + if (URL_FUNCTION_REGEX.test(bgImageString)) { + const uri = parseURLFunctionBody(bgImageString.slice('url('.length)); + if (uri != null && uri.length > 0) { // A url() in a css string is not an asset, so its natural size is only // known once the image is decoded. backgroundImages.push({ @@ -362,6 +357,137 @@ function parseBackgroundImageCSSString( return backgroundImages; } +const isCSSWhitespace = (char: string): boolean => + char === ' ' || + char === '\t' || + char === '\n' || + char === '\r' || + char === '\f'; + +const isCSSNewline = (char: string): boolean => + char === '\n' || char === '\r' || char === '\f'; + +// https://www.w3.org/TR/css-syntax-3/#non-printable-code-point +const isCSSNonPrintable = (char: string): boolean => { + const code = char.charCodeAt(0); + return ( + code <= 0x08 || + code === 0x0b || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ); +}; + +// Parses the text after `url(`. Returns the url, or null for an invalid url. +// The unquoted form follows https://www.w3.org/TR/css-syntax-3/#consume-url-token +// and the quoted form is a string token followed by `)`. +function parseURLFunctionBody(input: string): ?string { + let i = 0; + while (i < input.length && isCSSWhitespace(input[i])) { + i++; + } + + const quote = input[i]; + if (quote === '"' || quote === "'") { + let uri = ''; + i++; + while (true) { + if (i >= input.length || isCSSNewline(input[i])) { + return null; + } + const char = input[i]; + if (char === quote) { + i++; + break; + } + if (char === '\\') { + if (i + 1 < input.length && isCSSNewline(input[i + 1])) { + // An escaped newline is skipped. + i += 2; + continue; + } + if (i + 1 >= input.length) { + return null; + } + const [codePoint, length] = consumeCSSEscape(input, i + 1); + uri += codePoint; + i += 1 + length; + continue; + } + uri += char; + i++; + } + while (i < input.length && isCSSWhitespace(input[i])) { + i++; + } + return input[i] === ')' && i === input.length - 1 ? uri : null; + } + + let uri = ''; + while (i < input.length) { + const char = input[i]; + if (char === ')') { + return i === input.length - 1 ? uri : null; + } + if (isCSSWhitespace(char)) { + while (i < input.length && isCSSWhitespace(input[i])) { + i++; + } + return i >= input.length || (input[i] === ')' && i === input.length - 1) + ? uri + : null; + } + if ( + char === '"' || + char === "'" || + char === '(' || + isCSSNonPrintable(char) + ) { + return null; + } + if (char === '\\') { + if (i + 1 >= input.length || isCSSNewline(input[i + 1])) { + return null; + } + const [codePoint, length] = consumeCSSEscape(input, i + 1); + uri += codePoint; + i += 1 + length; + continue; + } + uri += char; + i++; + } + // A missing `)` at the end is a parse error, but the url is kept. + return uri; +} + +// https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point +// `start` is the index after the backslash. Returns the code point and the +// number of characters it used. +function consumeCSSEscape(input: string, start: number): [string, number] { + let i = start; + let hex = ''; + while (i < input.length && hex.length < 6 && /[0-9a-fA-F]/.test(input[i])) { + hex += input[i]; + i++; + } + if (hex.length === 0) { + return [input[start], 1]; + } + if (i < input.length && isCSSWhitespace(input[i])) { + i++; + } + let codePoint = parseInt(hex, 16); + if ( + codePoint === 0 || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + codePoint > 0x10ffff + ) { + codePoint = 0xfffd; + } + return [String.fromCodePoint(codePoint), i - start]; +} + function parseRadialGradientCSSString( gradientContent: string, ): RadialGradientBackgroundImage | null { diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp index 9ac8c9390e3b..4fc9706e77f3 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp @@ -25,6 +25,32 @@ namespace facebook::react { using RawValueMap = std::unordered_map; using RawValueList = std::vector; +// Returns nothing for a missing, non-string, or empty uri. +static std::optional parseURLBackgroundImage( + const RawValueMap& rawBackgroundImageMap) { + auto uriIt = rawBackgroundImageMap.find("uri"); + if (uriIt == rawBackgroundImageMap.end() || + !uriIt->second.hasType()) { + return std::nullopt; + } + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = (std::string)(uriIt->second); + if (urlBackgroundImage.uri.empty()) { + return std::nullopt; + } + auto widthIt = rawBackgroundImageMap.find("intrinsicWidth"); + if (widthIt != rawBackgroundImageMap.end() && + widthIt->second.hasType()) { + urlBackgroundImage.intrinsicWidth = (Float)(widthIt->second); + } + auto heightIt = rawBackgroundImageMap.find("intrinsicHeight"); + if (heightIt != rawBackgroundImageMap.end() && + heightIt->second.hasType()) { + urlBackgroundImage.intrinsicHeight = (Float)(heightIt->second); + } + return urlBackgroundImage; +} + inline GradientKeyword parseGradientKeyword(const std::string& keyword) { if (keyword == "to top right") { return GradientKeyword::ToTopRight; @@ -209,22 +235,9 @@ void parseProcessedBackgroundImage( backgroundImage.emplace_back(std::move(radialGradient)); } else if (type == "url") { - auto uriIt = rawBackgroundImageMap.find("uri"); - if (uriIt != rawBackgroundImageMap.end() && - uriIt->second.hasType()) { - URLBackgroundImage urlBackgroundImage; - urlBackgroundImage.uri = (std::string)(uriIt->second); - auto widthIt = rawBackgroundImageMap.find("intrinsicWidth"); - if (widthIt != rawBackgroundImageMap.end() && - widthIt->second.hasType()) { - urlBackgroundImage.intrinsicWidth = (Float)(widthIt->second); - } - auto heightIt = rawBackgroundImageMap.find("intrinsicHeight"); - if (heightIt != rawBackgroundImageMap.end() && - heightIt->second.hasType()) { - urlBackgroundImage.intrinsicHeight = (Float)(heightIt->second); - } - backgroundImage.emplace_back(std::move(urlBackgroundImage)); + if (auto urlBackgroundImage = + parseURLBackgroundImage(rawBackgroundImageMap)) { + backgroundImage.emplace_back(std::move(*urlBackgroundImage)); } } } @@ -428,22 +441,9 @@ void parseUnprocessedBackgroundImageList( backgroundImage.emplace_back(std::move(radialGradient)); } else if (type == "url") { - auto uriIt = rawBackgroundImageMap.find("uri"); - if (uriIt != rawBackgroundImageMap.end() && - uriIt->second.hasType()) { - URLBackgroundImage urlBackgroundImage; - urlBackgroundImage.uri = (std::string)(uriIt->second); - auto widthIt = rawBackgroundImageMap.find("intrinsicWidth"); - if (widthIt != rawBackgroundImageMap.end() && - widthIt->second.hasType()) { - urlBackgroundImage.intrinsicWidth = (Float)(widthIt->second); - } - auto heightIt = rawBackgroundImageMap.find("intrinsicHeight"); - if (heightIt != rawBackgroundImageMap.end() && - heightIt->second.hasType()) { - urlBackgroundImage.intrinsicHeight = (Float)(heightIt->second); - } - backgroundImage.emplace_back(std::move(urlBackgroundImage)); + if (auto urlBackgroundImage = + parseURLBackgroundImage(rawBackgroundImageMap)) { + backgroundImage.emplace_back(std::move(*urlBackgroundImage)); } } } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h index 6dc598f357a0..ddfa4e4a06a2 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h @@ -16,8 +16,6 @@ namespace facebook::react { -class ImageSource; - struct URLBackgroundImage { std::string uri{}; // Natural size in layout units, supplied by the asset registry. Zero when the From 0a4f8ebe23e7f84c85b205f680ff51d8fd9003c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 15:42:42 +0530 Subject: [PATCH 5/8] Add an RNTester example for a background image at its natural size A @3x asset with auto background size draws at its size in points, which shows that the intrinsic size from the asset registry is used. --- .../BackgroundImage/BackgroundImageExample.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js index 325649fcaf42..e577fefa6939 100644 --- a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js +++ b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js @@ -481,4 +481,28 @@ exports.examples = [ ); }, }, + { + title: 'URL Image from local file at its natural size', + name: 'local-image-natural-size', + description: + 'A @3x asset draws at its size in points (33x33), not at its pixel size (99x99).', + render(): React.Node { + return ( + + ); + }, + }, ] as Array; From f4b55f0fe61a68451dcf81243b8087a5dc4c2d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 17:02:27 +0530 Subject: [PATCH 6/8] Fix the Linux build, formatting, and C++ API snapshots for url() - consumePreservedToken for CSSURLFunction is no longer constexpr. CSSURLFunction holds a std::string, so with GCC 11's libstdc++ its std::optional is not a literal type and the Fantom build failed. - Run clang-format on CSSBackgroundImage.h and its test. - Update the C++ API snapshots to match the generated output. --- .../react/renderer/css/CSSBackgroundImage.h | 5 +- .../css/tests/CSSBackgroundImageTest.cpp | 84 ++++++++++++------- .../ReactAndroidFrameworksCxx.api | 1 + .../api-snapshots/ReactAppleFrameworksCxx.api | 1 + .../api-snapshots/ReactCommonDebugCxx.api | 4 +- .../ReactCommonFrameworksCxx.api | 1 + .../api-snapshots/ReactCommonNewarchCxx.api | 4 +- .../api-snapshots/ReactCommonReleaseCxx.api | 4 +- 8 files changed, 64 insertions(+), 40 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h index 57c2fc673b79..10b13234311e 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h @@ -831,7 +831,7 @@ struct CSSURLFunction { template <> struct CSSDataTypeParser { - static constexpr std::optional consumePreservedToken(const CSSPreservedToken &token) + static auto consumePreservedToken(const CSSPreservedToken &token) -> std::optional { if (token.type() != CSSTokenType::Url || token.stringValue().empty()) { return {}; @@ -843,7 +843,8 @@ struct CSSDataTypeParser { /* * A quoted url stays an ordinary function block holding a . */ - static auto consumeFunctionBlock(const CSSFunctionBlock &func, CSSValueParser &parser) -> std::optional + static auto consumeFunctionBlock(const CSSFunctionBlock &func, CSSValueParser &parser) + -> std::optional { if (!iequals(func.name, "url")) { return {}; diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp index 79546501bb88..b47194ef6a86 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp @@ -558,44 +558,50 @@ TEST_F(CSSBackgroundImageTest, RadialGradientNegativeRadius) { } TEST_F(CSSBackgroundImageTest, URLBasicUnquoted) { - auto result = - parseCSSProperty("url(https://example.com/image.png)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + auto result = parseCSSProperty( + "url(https://example.com/image.png)"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/image.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLDoubleQuoted) { - auto result = - parseCSSProperty("url(\"https://example.com/image.png\")"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + auto result = parseCSSProperty( + "url(\"https://example.com/image.png\")"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/image.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLSingleQuoted) { - auto result = - parseCSSProperty("url('https://example.com/image.png')"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + auto result = parseCSSProperty( + "url('https://example.com/image.png')"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/image.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLCaseInsensitive) { - auto result = - parseCSSProperty("UrL(https://example.com/image.png)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + auto result = parseCSSProperty( + "UrL(https://example.com/image.png)"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/image.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLWithQueryParams) { - auto result = - parseCSSProperty("url(https://example.com/image.png?size=Large&format=webp)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png?size=Large&format=webp"}; + auto result = parseCSSProperty( + "url(https://example.com/image.png?size=Large&format=webp)"); + decltype(result) expected = CSSURLFunction{ + .url = "https://example.com/image.png?size=Large&format=webp"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLWithWhitespace) { - auto result = - parseCSSProperty("url( https://example.com/image.png )"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + auto result = parseCSSProperty( + "url( https://example.com/image.png )"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/image.png"}; ASSERT_EQ(result, expected); } @@ -640,38 +646,50 @@ TEST_F(CSSBackgroundImageTest, URLEmptyQuoted) { } TEST_F(CSSBackgroundImageTest, URLWithPort) { - auto result = parseCSSProperty("url(http://localhost:8081/assets/bunny.png)"); - decltype(result) expected = CSSURLFunction{.url = "http://localhost:8081/assets/bunny.png"}; + auto result = parseCSSProperty( + "url(http://localhost:8081/assets/bunny.png)"); + decltype(result) expected = + CSSURLFunction{.url = "http://localhost:8081/assets/bunny.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLWithNumericQueryValues) { - auto result = parseCSSProperty("url(https://example.com/x.png?w=100&h=200)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/x.png?w=100&h=200"}; + auto result = parseCSSProperty( + "url(https://example.com/x.png?w=100&h=200)"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/x.png?w=100&h=200"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLWithNumericPathSegment) { - auto result = parseCSSProperty("url(https://example.com/img/2024/photo.png)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/img/2024/photo.png"}; + auto result = parseCSSProperty( + "url(https://example.com/img/2024/photo.png)"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/img/2024/photo.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLWithPercentEncoding) { - auto result = parseCSSProperty("url(https://example.com/my%20file.png)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/my%20file.png"}; + auto result = parseCSSProperty( + "url(https://example.com/my%20file.png)"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/my%20file.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLQuotedWithPort) { - auto result = parseCSSProperty("url(\"http://localhost:8081/1.png\")"); - decltype(result) expected = CSSURLFunction{.url = "http://localhost:8081/1.png"}; + auto result = parseCSSProperty( + "url(\"http://localhost:8081/1.png\")"); + decltype(result) expected = + CSSURLFunction{.url = "http://localhost:8081/1.png"}; ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLQuotedWithNumericQueryValues) { - auto result = parseCSSProperty("url('https://example.com/x.png?w=100&h=200')"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/x.png?w=100&h=200"}; + auto result = parseCSSProperty( + "url('https://example.com/x.png?w=100&h=200')"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/x.png?w=100&h=200"}; ASSERT_EQ(result, expected); } @@ -682,8 +700,10 @@ TEST_F(CSSBackgroundImageTest, URLBadUrlIsInvalid) { } TEST_F(CSSBackgroundImageTest, URLWithFragment) { - auto result = parseCSSProperty("url(https://example.com/sprite.svg#icon)"); - decltype(result) expected = CSSURLFunction{.url = "https://example.com/sprite.svg#icon"}; + auto result = parseCSSProperty( + "url(https://example.com/sprite.svg#icon)"); + decltype(result) expected = + CSSURLFunction{.url = "https://example.com/sprite.svg#icon"}; ASSERT_EQ(result, expected); } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index e2f2ccd3b0d9..bf9282a24743 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -8289,6 +8289,7 @@ struct facebook::react::CSSDataTypeParser : publ struct facebook::react::CSSDataTypeParser { public static std::optional consumeFunctionBlock(const facebook::react::CSSFunctionBlock& func, facebook::react::CSSValueParser& parser); + public static std::optional consumePreservedToken(const facebook::react::CSSPreservedToken& token); } struct facebook::react::CSSDataTypeParser { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index 61f467e02732..d1890fb4d425 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -10167,6 +10167,7 @@ struct facebook::react::CSSDataTypeParser : publ struct facebook::react::CSSDataTypeParser { public static std::optional consumeFunctionBlock(const facebook::react::CSSFunctionBlock& func, facebook::react::CSSValueParser& parser); + public static std::optional consumePreservedToken(const facebook::react::CSSPreservedToken& token); } struct facebook::react::CSSDataTypeParser { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index bcf6c74b6106..858fbe99bd8f 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -2072,10 +2072,10 @@ struct facebook::react::TransformOrigin { } struct facebook::react::URLBackgroundImage { + public Float intrinsicHeight; + public Float intrinsicWidth; public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; - public facebook::react::Float intrinsicHeight; - public facebook::react::Float intrinsicWidth; public std::string uri; } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index 232322a2cd26..d90b5c9ff8db 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -5524,6 +5524,7 @@ struct facebook::react::CSSDataTypeParser : publ struct facebook::react::CSSDataTypeParser { public static std::optional consumeFunctionBlock(const facebook::react::CSSFunctionBlock& func, facebook::react::CSSValueParser& parser); + public static std::optional consumePreservedToken(const facebook::react::CSSPreservedToken& token); } struct facebook::react::CSSDataTypeParser { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index b432024568c2..513d29f70e0e 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -2065,10 +2065,10 @@ struct facebook::react::TransformOrigin { } struct facebook::react::URLBackgroundImage { + public Float intrinsicHeight; + public Float intrinsicWidth; public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; - public facebook::react::Float intrinsicHeight; - public facebook::react::Float intrinsicWidth; public std::string uri; } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index b432024568c2..513d29f70e0e 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -2065,10 +2065,10 @@ struct facebook::react::TransformOrigin { } struct facebook::react::URLBackgroundImage { + public Float intrinsicHeight; + public Float intrinsicWidth; public bool operator!=(const facebook::react::URLBackgroundImage& rhs) const; public bool operator==(const facebook::react::URLBackgroundImage& rhs) const; - public facebook::react::Float intrinsicHeight; - public facebook::react::Float intrinsicWidth; public std::string uri; } From 6c4941c35c84935a1a93baa8a190ed6ab66e741a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 18:54:21 +0530 Subject: [PATCH 7/8] Match web for empty and invalid background url() layers Checked against Chrome: - An empty url (`url()`, `url("")`, `{uri: ''}`) is valid. It stays a layer that draws nothing, so the other layers and their background sizes and positions keep their index. - An invalid url (`url(my photo.png)`) makes the whole list invalid, in the JS string parser and natively. - A malformed url object (missing or non-string uri, or an unknown asset) makes the whole list invalid, like an invalid gradient. --- .../__tests__/View-nativeCSSParsing-itest.js | 38 ++++++++++++++++++- .../__tests__/processBackgroundImage-itest.js | 34 ++++++++++++----- .../StyleSheet/processBackgroundImage.js | 19 ++++++---- .../view/BackgroundImagePropsConversions.cpp | 22 ++++++----- .../react/renderer/css/CSSBackgroundImage.h | 4 +- .../css/tests/CSSBackgroundImageTest.cpp | 6 ++- 6 files changed, 91 insertions(+), 32 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js index 63688483c692..1146352c65f0 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js @@ -11,6 +11,8 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +import type {BackgroundImageValue} from '../../../StyleSheet/StyleSheetTypes'; + import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {View} from 'react-native'; @@ -72,12 +74,46 @@ describe(' native CSS parsing', () => { expect(backgroundImage).toContain('rgba(230, 100, 101, 1)'); }); - it('drops a url layer with an empty uri', () => { + it('keeps a url layer with an empty uri, like web', () => { expect( mountedProp( {backgroundImage: [{type: 'url', uri: ''}]}, 'backgroundImage', ), + ).toBe('[url()]'); + const backgroundImage = mountedProp( + {backgroundImage: 'url(""), linear-gradient(red, blue)'}, + 'backgroundImage', + ); + expect(backgroundImage).toContain('url()'); + expect(backgroundImage).toContain('linear-gradient'); + }); + + it('drops the whole list when a url layer is invalid, like web', () => { + const gradient: BackgroundImageValue = { + type: 'linear-gradient', + direction: 'to bottom', + colorStops: [ + {color: 'red', positions: ['0%']}, + {color: 'blue', positions: ['100%']}, + ], + }; + expect( + mountedProp({backgroundImage: [gradient]}, 'backgroundImage'), + ).toContain('linear-gradient'); + // An asset number that the asset registry does not know stays a number, + // which is not a valid uri. + expect( + mountedProp( + {backgroundImage: [{type: 'url', uri: 999999}, gradient]}, + 'backgroundImage', + ), + ).toBeUndefined(); + expect( + mountedProp( + {backgroundImage: 'url(my photo.png), linear-gradient(red, blue)'}, + 'backgroundImage', + ), ).toBeUndefined(); }); diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 5364f8619593..38da093a8aeb 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js @@ -1299,19 +1299,35 @@ describe('processBackgroundImage', () => { ]); }); - it('should return empty for url empty', () => { - const result = processBackgroundImage('url()'); - expect(result).toEqual([]); + // An empty url is valid CSS. It draws nothing, but it stays a layer, so the + // other layers and their background sizes and positions keep their index. + it('should keep an empty url as a layer', () => { + expect(processBackgroundImage('url()')).toEqual([{type: 'url', uri: ''}]); }); - it('should return empty for url empty quoted', () => { - const result = processBackgroundImage('url("")'); - expect(result).toEqual([]); + it('should keep an empty quoted url as a layer', () => { + expect(processBackgroundImage('url("")')).toEqual([{type: 'url', uri: ''}]); }); - it('should return empty for an empty uri in the object syntax', () => { - const result = processBackgroundImage([{type: 'url', uri: ''}]); - expect(result).toEqual([]); + it('should keep an empty uri in the object syntax as a layer', () => { + expect(processBackgroundImage([{type: 'url', uri: ''}])).toEqual([ + {type: 'url', uri: ''}, + ]); + }); + + it('should keep the other layers next to an empty url', () => { + const result = processBackgroundImage( + 'url(""), linear-gradient(red, blue)', + ); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({type: 'url', uri: ''}); + expect(result[1].type).toBe('linear-gradient'); + }); + + it('should return empty when one url is invalid', () => { + expect( + processBackgroundImage('url(my photo.png), linear-gradient(red, blue)'), + ).toEqual([]); }); it('should keep parentheses inside a quoted url', () => { diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index f52f045a5c83..1ecfed3398cd 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -111,7 +111,7 @@ export function processBackgroundImageAssets( function processURLBackgroundImage(uri: number | string): ?URLBackgroundImage { if (typeof uri === 'string') { - return uri.length > 0 ? {type: 'url', uri} : null; + return {type: 'url', uri}; } const source = resolveAssetSource(uri); if (source == null || source.uri == null) { @@ -329,14 +329,17 @@ function parseBackgroundImageCSSString( for (const bgImageString of bgImageStrings) { if (URL_FUNCTION_REGEX.test(bgImageString)) { const uri = parseURLFunctionBody(bgImageString.slice('url('.length)); - if (uri != null && uri.length > 0) { - // A url() in a css string is not an asset, so its natural size is only - // known once the image is decoded. - backgroundImages.push({ - type: 'url', - uri, - }); + if (uri == null) { + // An invalid url makes the whole list invalid. Same as web. + return []; } + // A url() in a css string is not an asset, so its natural size is only + // known once the image is decoded. An empty url stays as a layer that + // draws nothing. Same as web. + backgroundImages.push({ + type: 'url', + uri, + }); continue; } diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp index 4fc9706e77f3..a4084b91a02e 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp @@ -25,7 +25,8 @@ namespace facebook::react { using RawValueMap = std::unordered_map; using RawValueList = std::vector; -// Returns nothing for a missing, non-string, or empty uri. +// Returns nothing for a missing or non-string uri. An empty uri stays as a +// layer that draws nothing, like `url("")` on the web. static std::optional parseURLBackgroundImage( const RawValueMap& rawBackgroundImageMap) { auto uriIt = rawBackgroundImageMap.find("uri"); @@ -35,9 +36,6 @@ static std::optional parseURLBackgroundImage( } URLBackgroundImage urlBackgroundImage; urlBackgroundImage.uri = (std::string)(uriIt->second); - if (urlBackgroundImage.uri.empty()) { - return std::nullopt; - } auto widthIt = rawBackgroundImageMap.find("intrinsicWidth"); if (widthIt != rawBackgroundImageMap.end() && widthIt->second.hasType()) { @@ -235,10 +233,12 @@ void parseProcessedBackgroundImage( backgroundImage.emplace_back(std::move(radialGradient)); } else if (type == "url") { - if (auto urlBackgroundImage = - parseURLBackgroundImage(rawBackgroundImageMap)) { - backgroundImage.emplace_back(std::move(*urlBackgroundImage)); + auto urlBackgroundImage = parseURLBackgroundImage(rawBackgroundImageMap); + if (!urlBackgroundImage) { + result = {}; + return; } + backgroundImage.emplace_back(std::move(*urlBackgroundImage)); } } @@ -441,10 +441,12 @@ void parseUnprocessedBackgroundImageList( backgroundImage.emplace_back(std::move(radialGradient)); } else if (type == "url") { - if (auto urlBackgroundImage = - parseURLBackgroundImage(rawBackgroundImageMap)) { - backgroundImage.emplace_back(std::move(*urlBackgroundImage)); + auto urlBackgroundImage = parseURLBackgroundImage(rawBackgroundImageMap); + if (!urlBackgroundImage) { + result = {}; + return; } + backgroundImage.emplace_back(std::move(*urlBackgroundImage)); } } diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h index 10b13234311e..78ab70fb9065 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h @@ -833,7 +833,7 @@ template <> struct CSSDataTypeParser { static auto consumePreservedToken(const CSSPreservedToken &token) -> std::optional { - if (token.type() != CSSTokenType::Url || token.stringValue().empty()) { + if (token.type() != CSSTokenType::Url) { return {}; } @@ -861,7 +861,7 @@ struct CSSDataTypeParser { return std::string(token.stringValue()); }); - if (!url.has_value() || url->empty()) { + if (!url.has_value()) { return {}; } diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp index b47194ef6a86..3a3db02065ce 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp @@ -637,12 +637,14 @@ TEST_F(CSSBackgroundImageTest, URLMixedWithGradients) { TEST_F(CSSBackgroundImageTest, URLEmpty) { auto result = parseCSSProperty("url()"); - ASSERT_TRUE(std::holds_alternative(result)); + decltype(result) expected = CSSURLFunction{.url = ""}; + ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLEmptyQuoted) { auto result = parseCSSProperty("url(\"\")"); - ASSERT_TRUE(std::holds_alternative(result)); + decltype(result) expected = CSSURLFunction{.url = ""}; + ASSERT_EQ(result, expected); } TEST_F(CSSBackgroundImageTest, URLWithPort) { From 7f98fc18cc51b11b1af2ead4f91ef6c52ad2f061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 19:21:20 +0530 Subject: [PATCH 8/8] Do not decode CSS escapes in background url() in JS The native CSS parser treats a backslash in url() as an ordinary character. The JS parser now does the same, so both parsers give the same result. --- .../__tests__/processBackgroundImage-itest.js | 26 ++++++---- .../StyleSheet/processBackgroundImage.js | 50 ------------------- 2 files changed, 17 insertions(+), 59 deletions(-) diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 38da093a8aeb..8fc19c32df1b 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js @@ -1377,16 +1377,24 @@ describe('processBackgroundImage', () => { }); } - for (const [name, backgroundImage, uri] of [ - ['an escaped parenthesis', 'url(a\\)b.png)', 'a)b.png'], - ['an escaped quote in a quoted url', 'url("a\\"b.png")', 'a"b.png'], - ['a hex escape', 'url(a\\20 b.png)', 'a b.png'], - ['a missing closing parenthesis', 'url(a.png', 'a.png'], + it('should parse a url with a missing closing parenthesis', () => { + expect(processBackgroundImage('url(a.png')).toEqual([ + {type: 'url', uri: 'a.png'}, + ]); + }); + + it('should keep a backslash in a quoted url', () => { + expect(processBackgroundImage('url("a\\20b.png")')).toEqual([ + {type: 'url', uri: 'a\\20b.png'}, + ]); + }); + + for (const [name, backgroundImage] of [ + ['an escaped parenthesis', 'url(a\\)b.png)'], + ['an escaped quote in a quoted url', 'url("a\\"b.png")'], ]) { - it(`should parse a url with ${name}`, () => { - expect(processBackgroundImage(backgroundImage)).toEqual([ - {type: 'url', uri}, - ]); + it(`should return empty for ${name}`, () => { + expect(processBackgroundImage(backgroundImage)).toEqual([]); }); } }); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 1ecfed3398cd..7463e2b60d93 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -403,20 +403,6 @@ function parseURLFunctionBody(input: string): ?string { i++; break; } - if (char === '\\') { - if (i + 1 < input.length && isCSSNewline(input[i + 1])) { - // An escaped newline is skipped. - i += 2; - continue; - } - if (i + 1 >= input.length) { - return null; - } - const [codePoint, length] = consumeCSSEscape(input, i + 1); - uri += codePoint; - i += 1 + length; - continue; - } uri += char; i++; } @@ -448,15 +434,6 @@ function parseURLFunctionBody(input: string): ?string { ) { return null; } - if (char === '\\') { - if (i + 1 >= input.length || isCSSNewline(input[i + 1])) { - return null; - } - const [codePoint, length] = consumeCSSEscape(input, i + 1); - uri += codePoint; - i += 1 + length; - continue; - } uri += char; i++; } @@ -464,33 +441,6 @@ function parseURLFunctionBody(input: string): ?string { return uri; } -// https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point -// `start` is the index after the backslash. Returns the code point and the -// number of characters it used. -function consumeCSSEscape(input: string, start: number): [string, number] { - let i = start; - let hex = ''; - while (i < input.length && hex.length < 6 && /[0-9a-fA-F]/.test(input[i])) { - hex += input[i]; - i++; - } - if (hex.length === 0) { - return [input[start], 1]; - } - if (i < input.length && isCSSWhitespace(input[i])) { - i++; - } - let codePoint = parseInt(hex, 16); - if ( - codePoint === 0 || - (codePoint >= 0xd800 && codePoint <= 0xdfff) || - codePoint > 0x10ffff - ) { - codePoint = 0xfffd; - } - return [String.fromCodePoint(codePoint), i - start]; -} - function parseRadialGradientCSSString( gradientContent: string, ): RadialGradientBackgroundImage | null {