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
7373180
feat(styles): add conic gradient parsing
Stringsaeed Aug 25, 2026
105e721
feat(view): render conic gradients natively
Stringsaeed Aug 25, 2026
464f83f
test: cover conic gradient parsing and rendering edge cases
Stringsaeed Sep 30, 2026
ceb2e2b
fix: respect conic gradient right and bottom offsets
Stringsaeed Sep 30, 2026
665ffb6
fix: validate commas in conic gradient syntax
Stringsaeed Sep 30, 2026
e7731fb
fix: validate conic gradient object color stops
Stringsaeed Sep 30, 2026
3066d62
fix: preserve conic gradient angles on rectangular iOS views
Stringsaeed Sep 30, 2026
460244d
fix: allow negative conic gradient offsets on Android
Stringsaeed Sep 30, 2026
655147a
fix: adapt conic gradient colors to upstream conversion
Stringsaeed Oct 6, 2026
81ea9d4
fix: align conic gradient parsing across paths
Stringsaeed Oct 6, 2026
2e518d2
fix: preserve negative conic color stop positions on Android
Stringsaeed Oct 6, 2026
0dcd9c6
fix: type conic gradient transition hints
Stringsaeed Oct 6, 2026
734d876
docs: explain conic background image support
Stringsaeed Oct 6, 2026
d41b5ad
test: benchmark conic gradient parsing
Stringsaeed Oct 6, 2026
0b44192
perf: avoid duplicate conic gradient stop splitting
Stringsaeed Oct 6, 2026
52bad8a
docs: remove conic gradient documentation
Stringsaeed Oct 6, 2026
6907ddc
fix: reject pixel strings in gradient object stops
Stringsaeed Oct 22, 2026
8c71f94
chore: regenerate conic gradient API snapshots
Stringsaeed Oct 22, 2026
18f18d1
style: format conic gradient with current Kotlin formatter
Stringsaeed Oct 22, 2026
2f1492a
fix: add conic gradient to legacy TypeScript types
Stringsaeed Oct 23, 2026
2b9f16d
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