Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
98b9440
feat(styles): add conic gradient parsing
Stringsaeed Aug 25, 2026
12dda51
feat(view): render conic gradients natively
Stringsaeed Aug 25, 2026
f86dbe6
test: cover conic gradient parsing and rendering edge cases
Stringsaeed Sep 30, 2026
88d95da
fix: respect conic gradient right and bottom offsets
Stringsaeed Sep 30, 2026
46abf9d
fix: validate commas in conic gradient syntax
Stringsaeed Sep 30, 2026
ed039f3
fix: validate conic gradient object color stops
Stringsaeed Sep 30, 2026
94d8bdc
fix: preserve conic gradient angles on rectangular iOS views
Stringsaeed Sep 30, 2026
b457821
fix: allow negative conic gradient offsets on Android
Stringsaeed Sep 30, 2026
3dffe67
fix: adapt conic gradient colors to upstream conversion
Stringsaeed Oct 6, 2026
50f0bed
fix: align conic gradient parsing across paths
Stringsaeed Oct 6, 2026
1702476
fix: preserve negative conic color stop positions on Android
Stringsaeed Oct 6, 2026
172ac28
fix: type conic gradient transition hints
Stringsaeed Oct 6, 2026
77b8ada
docs: explain conic background image support
Stringsaeed Oct 6, 2026
24d5c22
test: benchmark conic gradient parsing
Stringsaeed Oct 6, 2026
21a5a8c
perf: avoid duplicate conic gradient stop splitting
Stringsaeed Oct 6, 2026
39d02a1
docs: remove conic gradient documentation
Stringsaeed Oct 6, 2026
bb6a02c
fix: reject pixel strings in gradient object stops
Stringsaeed Oct 22, 2026
cba3422
chore: regenerate conic gradient API snapshots
Stringsaeed Oct 22, 2026
83bc4fc
style: format conic gradient with current Kotlin formatter
Stringsaeed Oct 22, 2026
b418cf4
fix: add conic gradient to legacy TypeScript types
Stringsaeed Oct 23, 2026
9c3254d
fix: accept three-value conic gradient positions
Stringsaeed Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @fantom_flags enableNativeCSSParsing:*
* @fantom_flags enableCppPropsIteratorSetter:*
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import type {ColorValue} from '../../../StyleSheet/StyleSheet';
import type {
BackgroundImageValue,
RadialGradientPosition,
} from '../../../StyleSheet/StyleSheetTypes';

import * as Fantom from '@react-native/fantom';
import * as React from 'react';
import {View} from 'react-native';

type BackgroundImage = string | ReadonlyArray<BackgroundImageValue>;

function mountedBackgroundImage(backgroundImage: BackgroundImage): ?string {
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<View collapsable={false} style={{backgroundImage}} />);
});
return root.getRenderedOutput({props: ['backgroundImage']}).toJSONObject()
.props.backgroundImage;
}

const colors: ReadonlyArray<{
color: ColorValue,
positions?: ReadonlyArray<string>,
}> = [{color: 'red'}, {color: 'blue'}];
const serializedColors = 'rgba(255, 0, 0, 1), rgba(0, 0, 255, 1)';

describe('conic gradient native props', () => {
// Assert the complete native value: matching only the right/bottom suffix
// would miss stale top/left defaults, which take precedence on iOS.
const positions: Array<{
css: string,
object: RadialGradientPosition,
expected: string,
}> = [
{css: 'center', object: {left: '50%', top: '50%'}, expected: '50% 50%'},
{
css: 'top center',
object: {left: '50%', top: '0%'},
expected: '50% 0%',
},
{
css: 'bottom center',
object: {left: '50%', top: '100%'},
expected: '50% 100%',
},
{
css: 'center left',
object: {left: '0%', top: '50%'},
expected: '0% 50%',
},
{
css: 'center right',
object: {left: '100%', top: '50%'},
expected: '100% 50%',
},
{css: '25% 75%', object: {left: '25%', top: '75%'}, expected: '25% 75%'},
{
css: 'left 10px top 20px',
object: {left: 10, top: 20},
expected: '10px 20px',
},
{
css: 'right 10px top 20px',
object: {right: 10, top: 20},
expected: '20px 10px',
},
{
css: 'left 10px bottom 20px',
object: {left: 10, bottom: 20},
expected: '10px 20px',
},
{
css: 'right 10px bottom 20px',
object: {right: 10, bottom: 20},
expected: '10px 20px',
},
{
css: 'bottom 20px right 10px',
object: {bottom: 20, right: 10},
expected: '10px 20px',
},
{
css: 'right 0px bottom 0px',
object: {right: 0, bottom: 0},
expected: '0px 0px',
},
{
css: 'right 10% bottom 20%',
object: {right: '10%', bottom: '20%'},
expected: '10% 20%',
},
{
css: 'right -10px bottom -20px',
object: {right: -10, bottom: -20},
expected: '-10px -20px',
},
{
css: 'right 110% bottom 120%',
object: {right: '110%', bottom: '120%'},
expected: '110% 120%',
},
{
css: 'right 0.5px bottom 1.25px',
object: {right: 0.5, bottom: 1.25},
expected: '0.5px 1.25px',
},
];

for (const {css, object, expected} of positions) {
it(`preserves string position ${css}`, () => {
expect(
mountedBackgroundImage(
`conic-gradient(from 0deg at ${css}, red, blue)`,
),
).toBe(
`[conic-gradient(from 0deg at ${expected} , ${serializedColors})]`,
);
});
it(`preserves object position ${JSON.stringify(object)}`, () => {
expect(
mountedBackgroundImage([
{type: 'conic-gradient', position: object, colorStops: colors},
]),
).toBe(
`[conic-gradient(from 0deg at ${expected} , ${serializedColors})]`,
);
});
}

for (const {position, expected} of [
{position: {right: 10}, expected: '50% 10px'},
{position: {bottom: 20}, expected: '50% 20px'},
{position: {left: 10}, expected: '10px 50%'},
{position: {top: 20}, expected: '50% 20px'},
]) {
it(`centers only the unspecified axis for ${JSON.stringify(position)}`, () => {
expect(
// $FlowFixMe[incompatible-type] - exercise partial positions from untyped callers.
mountedBackgroundImage([
{type: 'conic-gradient', position, colorStops: colors},
]),
).toBe(
`[conic-gradient(from 0deg at ${expected} , ${serializedColors})]`,
);
});
}

for (const [angle, expected] of [
['0', '0'],
['90deg', '90'],
['100grad', '90'],
['0.25turn', '90'],
['-90deg', '-90'],
['720deg', '720'],
]) {
for (const syntax of ['string', 'object']) {
it(`preserves ${angle} in ${syntax} syntax`, () => {
const input: BackgroundImage =
syntax === 'string'
? `conic-gradient(from ${angle}, red, blue)`
: [{type: 'conic-gradient', from: angle, colorStops: colors}];
expect(mountedBackgroundImage(input)).toBe(
`[conic-gradient(from ${expected}deg at 50% 50% , ${serializedColors})]`,
);
});
}
}

it('expands two-position hard stops without losing their order', () => {
expect(
mountedBackgroundImage(
'conic-gradient(red 0deg 90deg, blue 90deg 1turn)',
),
).toBe(
'[conic-gradient(from 0deg at 50% 50% , rgba(255, 0, 0, 1) 0%, rgba(255, 0, 0, 1) 25%, rgba(0, 0, 255, 1) 25%, rgba(0, 0, 255, 1) 100%)]',
);
});

it('accepts a single two-position hard stop', () => {
expect(mountedBackgroundImage('conic-gradient(red 0deg 360deg)')).toBe(
'[conic-gradient(from 0deg at 50% 50% , rgba(255, 0, 0, 1) 0%, rgba(255, 0, 0, 1) 100%)]',
);
});

for (const invalid of [
'conic-gradient()',
'conic-gradient(red)',
'conic-gradient(from 10px, red, blue)',
'conic-gradient(from 1, red, blue)',
'conic-gradient(from 50%, red, blue)',
'conic-gradient(from, red, blue)',
'conic-gradient(at, red, blue)',
'conic-gradient(at right left, red, blue)',
'conic-gradient(at left right, red, blue)',
'conic-gradient(at right 10px left 20px, red, blue)',
'conic-gradient(at 20px left, red, blue)',
'conic-gradient(at 20px right, red, blue)',
'conic-gradient(at top 20px, red, blue)',
'conic-gradient(at bottom 20px, red, blue)',
'conic-gradient(red 10px, blue)',
'conic-gradient(red 20, blue)',
'conic-gradient(20%, red, blue)',
'conic-gradient(red, blue, 20%)',
'conic-gradient(red, 20%, 30%, blue)',
'conic-gradient(red,, blue)',
'conic-gradient(, red, blue)',
'conic-gradient(red, blue,)',
'conic-gradient(red, not-a-color)',
'conic-gradient(from 90deg red, blue)',
'conic-gradient(at center red, blue)',
]) {
it(`rejects ${invalid}`, () => {
expect(mountedBackgroundImage(invalid)).toBeUndefined();
});
}

for (const colorStops of [
[{color: 'red', positions: ['0deg', '45deg', '90deg']}, {color: 'blue'}],
[{positions: ['20%']}, {color: 'red'}, {color: 'blue'}],
[{color: 'red'}, {color: 'blue'}, {positions: ['20%']}],
[
{color: 'red'},
{positions: ['20%']},
{positions: ['30%']},
{color: 'blue'},
],
[{color: 'red'}, {positions: ['20%', '30%']}, {color: 'blue'}],
[
{color: 'red'},
{color: 'not-a-color', positions: ['25%']},
{color: 'blue'},
],
]) {
it(`rejects malformed object stops through native props: ${JSON.stringify(colorStops)}`, () => {
const input = [{type: 'conic-gradient', colorStops}];
// $FlowFixMe[incompatible-type] - exercise malformed stops from untyped callers.
expect(mountedBackgroundImage(input)).toBeUndefined();
});
}

for (const color of [undefined, null]) {
it(`preserves a valid object hint with color ${String(color)}`, () => {
const input: BackgroundImage = [
{
type: 'conic-gradient',
colorStops: [
{color: 'red'},
{color, positions: ['90deg']},
{color: 'blue'},
],
},
];
expect(mountedBackgroundImage(input)).toBe(
'[conic-gradient(from 0deg at 50% 50% , rgba(255, 0, 0, 1), rgba(0, 0, 0, 0) 25%, rgba(0, 0, 255, 1))]',
);
});
}

it('preserves layer order when conic, linear, and radial gradients are mixed', () => {
expect(
mountedBackgroundImage(
'conic-gradient(red, blue), linear-gradient(red, blue), radial-gradient(red, blue)',
),
).toBe(
`[conic-gradient(from 0deg at 50% 50% , ${serializedColors}), linear-gradient(180deg, ${serializedColors}), radial-gradient(ellipse farthest-corner at 50% 50% , ${serializedColors})]`,
);
});

const updates: Array<{
name: string,
before: BackgroundImage,
after: BackgroundImage | void,
expected: ?string,
}> = [
{
name: 'replaces left/top with right/bottom',
before: 'conic-gradient(at left 30px top 40px, red, blue)',
after: [
{
type: 'conic-gradient',
position: {right: 10, bottom: 20},
colorStops: colors,
},
],
expected: `[conic-gradient(from 0deg at 10px 20px , ${serializedColors})]`,
},
{
name: 'resets edge offsets to the default center',
before: 'conic-gradient(at right 10px bottom 20px, red, blue)',
after: 'conic-gradient(red, blue)',
expected: `[conic-gradient(from 0deg at 50% 50% , ${serializedColors})]`,
},
{
name: 'removes a gradient with an empty list',
before: 'conic-gradient(red, blue)',
after: [],
expected: undefined,
},
{
name: 'removes a gradient with undefined',
before: 'conic-gradient(red, blue)',
after: undefined,
expected: undefined,
},
{
name: 'clears a gradient after an invalid replacement',
before: 'conic-gradient(red, blue)',
after: 'conic-gradient(red 10px, blue)',
expected: undefined,
},
{
name: 'replaces a conic gradient with a linear gradient',
before: 'conic-gradient(red, blue)',
after: 'linear-gradient(red, blue)',
expected: `[linear-gradient(180deg, ${serializedColors})]`,
},
{
name: 'recovers after invalid input',
before: 'conic-gradient(red 10px, blue)',
after: 'conic-gradient(red, blue)',
expected: `[conic-gradient(from 0deg at 50% 50% , ${serializedColors})]`,
},
];
for (const {name, before, after, expected} of updates) {
it(name, () => {
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<View collapsable={false} style={{backgroundImage: before}} />,
);
});
Fantom.runTask(() => {
root.render(
<View collapsable={false} style={{backgroundImage: after}} />,
);
});
expect(
root.getRenderedOutput({props: ['backgroundImage']}).toJSONObject()
.props.backgroundImage,
).toBe(expected);
});
}
});
Loading
Loading