Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Native runtime:
- Resolved styles subscribe to only dependencies they use, then invalidate cache entries on change.
- Runtime dependencies are represented by `StyleDependency`: theme, dimensions, orientation, insets, font scale, RTL, adaptive themes, and variables.
- Native style resolution filters rules by screen width, orientation, theme, RTL, active/focus/disabled state, and `data-*` props.
- Native post-processing adapts CSS concepts to RN shapes, including line-height multipliers, shadows, transforms, gradients, visibility, borders, outlines, and font variants.
- Native post-processing adapts CSS concepts to RN shapes, including line-height multipliers, shadows, transforms, gradients, visibility, borders, outlines, font variants, and filters.

Web runtime:

Expand Down Expand Up @@ -145,6 +145,9 @@ Important concepts:
- Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata.
- Important declarations are preserved as `importantProperties`.
- Unsupported CSS features may be silently ignored on native. Prefer documenting support coverage over adding noisy runtime failures for every unsupported CSS construct.
- Tailwind composes `filter` from per-utility `--tw-*` variables and relies on `var(--x,)` empty fallbacks for unset parts, so `Var` resolves those to an empty string. Each filter function compiles to `rt.filterFn(name, amount, unit)` because `addMissingSpaces` would otherwise corrupt an inline `blur(${...}px)` template.
- Filter runtime support is platform-dependent: Android applies filters at the default release level (blur and drop-shadow need API 31+, and one blur in the chain sends the whole chain down that path), while iOS renders blur/grayscale/saturate/contrast/hue-rotate only behind the `enableSwiftUIBasedFilters` React Native feature flag — experimental in RN 0.83-0.86, canary in 0.87, absent before 0.83.
- `backdrop-filter` has no RN equivalent and is still dropped.
Comment thread
Brentlok marked this conversation as resolved.

Web visitor behavior:

Expand Down
47 changes: 31 additions & 16 deletions packages/uniwind/src/bundler/css-processor/functions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import type { CalcFor_DimensionPercentageFor_LengthValue, CalcFor_Length, CssColor, Function as FunctionType } from 'lightningcss'
import { Logger } from '../logger'
import type { ProcessorBuilder } from './processor'
import { pipe } from './utils'
import { pipe, roundToPrecision } from './utils'

const FILTER_FUNCTIONS = new Set([
'blur',
'brightness',
'contrast',
'grayscale',
'hue-rotate',
'invert',
'opacity',
'saturate',
'sepia',
])

const FILTER_UNITS: Record<string, string> = {
blur: 'px',
}

export class Functions {
private readonly logger = new Logger('Functions')
Expand Down Expand Up @@ -86,21 +102,11 @@ export class Functions {
return this.Processor.Color.processColor(color as CssColor)
}

if (
[
'blur',
'brightness',
'contrast',
'grayscale',
'hue-rotate',
'invert',
'opacity',
'saturate',
'sepia',
'conic-gradient',
'radial-gradient',
].includes(fn.name)
) {
if (FILTER_FUNCTIONS.has(fn.name)) {
return this.processFilterFunction(fn)
}

if (['conic-gradient', 'radial-gradient'].includes(fn.name)) {
// Not supported by RN
return '""'
}
Expand Down Expand Up @@ -194,6 +200,15 @@ export class Functions {
}
}

private processFilterFunction(fn: FunctionType) {
const [argument] = fn.arguments
const amount = argument?.type === 'token' && argument.value.type === 'percentage'
? roundToPrecision(argument.value.value, 2)
: this.Processor.CSS.processValue(fn.arguments)

return `rt.filterFn("${fn.name}", ${amount}, "${FILTER_UNITS[fn.name] ?? ''}")`
}

private processColorMix(fn: FunctionType) {
const tokens = fn.arguments
.map(arg =>
Expand Down
9 changes: 8 additions & 1 deletion packages/uniwind/src/bundler/css-processor/var.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ export class Var {
processVar(variable: Variable): string {
const value = `vars[${JSON.stringify(variable.name.ident)}]?.(vars)`

if (!variable.fallback || variable.fallback.length === 0) {
if (!variable.fallback) {
return value
}

// `var(--x,)` declares an empty fallback, which Tailwind relies on to compose
// optional parts (filters), so it must resolve to an empty string, not `undefined`.
// The trailing space keeps consecutive parts separate tokens for serialization.
if (variable.fallback.length === 0) {
return `${value} ?? "" `
}

const fallback = this.Processor.CSS.processValue(variable.fallback)

return `${value} ?? ${fallback}`
Expand Down
1 change: 1 addition & 0 deletions packages/uniwind/src/core/native/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const UniwindRuntime = {
right: 0,
},
colorMix,
filterFn: (name: string, amount: number | string, unit: string) => `${name}(${amount}${unit})`,
pixelRatio: value => value * PixelRatio.get(),
cubicBezier: () => '',
lightDark: () => '',
Expand Down
93 changes: 93 additions & 0 deletions packages/uniwind/tests/native/styles-parsing/filters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as React from 'react'
import View from '../../../src/components/native/View'
import type { RNStyle } from '../../../src/core/types'
import { renderUniwind } from '../utils'

const filterOf = ({ filter }: RNStyle) =>
typeof filter === 'string'
? filter.replace(/\s+/g, ' ').trim()
: filter

describe('Filters', () => {
test('Blur', () => {
const { getStylesFromId } = renderUniwind(
<View
className="blur-md"
testID="blur-md"
/>,
)

expect(filterOf(getStylesFromId('blur-md'))).toBe('blur(12px)')
})

test('Arbitrary blur', () => {
const { getStylesFromId } = renderUniwind(
<View
className="blur-[7px]"
testID="blur-arbitrary"
/>,
)

expect(filterOf(getStylesFromId('blur-arbitrary'))).toBe('blur(7px)')
})

test('Amount filters', () => {
const { getStylesFromId } = renderUniwind(
<React.Fragment>
<View
className="grayscale"
testID="grayscale"
/>
<View
className="invert"
testID="invert"
/>
<View
className="sepia"
testID="sepia"
/>
<View
className="saturate-150"
testID="saturate"
/>
<View
className="brightness-50"
testID="brightness"
/>
<View
className="contrast-125"
testID="contrast"
/>
</React.Fragment>,
)

expect(filterOf(getStylesFromId('grayscale'))).toBe('grayscale(1)')
expect(filterOf(getStylesFromId('invert'))).toBe('invert(1)')
expect(filterOf(getStylesFromId('sepia'))).toBe('sepia(1)')
expect(filterOf(getStylesFromId('saturate'))).toBe('saturate(1.5)')
expect(filterOf(getStylesFromId('brightness'))).toBe('brightness(0.5)')
expect(filterOf(getStylesFromId('contrast'))).toBe('contrast(1.25)')
})

test('Hue rotate', () => {
const { getStylesFromId } = renderUniwind(
<View
className="hue-rotate-90"
testID="hue-rotate"
/>,
)

expect(filterOf(getStylesFromId('hue-rotate'))).toBe('hue-rotate(90deg)')
})

test('Combined filters keep CSS order', () => {
const { getStylesFromId } = renderUniwind(
<View
className="grayscale blur-md"
testID="combined"
/>,
)

expect(filterOf(getStylesFromId('combined'))).toBe('blur(12px) grayscale(1)')
})
})