Skip to content
Open
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
7 changes: 6 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Core user-facing features:
- CSS custom property reads and updates from React Native code.
- Scoped themes through `ScopedTheme`.
- Scoped layout direction through `LayoutDirection`.
- Scoped CSS variables through `ScopedVariables`.
- Metro and Vite integration.

Supported platforms: iOS, Android, web, Android TV, and Apple TV. Other React Native targets are out of scope until tests and docs explicitly cover them.
Expand All @@ -41,6 +42,7 @@ Public exports from `src/index.ts`:
- `Uniwind` runtime/config object.
- `LayoutDirection` component.
- `ScopedTheme` component.
- `ScopedVariables` component.
- `withUniwind` HOC and related types.
- `useCSSVariable`, `useResolveClassNames`, `useUniwind` hooks.
- `ThemeName` and `UniwindConfig` types.
Expand All @@ -65,7 +67,8 @@ Native runtime:
- Build output injects a generated stylesheet callback into `Uniwind.__reinit(...)`.
- `UniwindStore` holds generated style records, theme variables, scoped variables, runtime state, and per-theme caches.
- `UniwindStore.getStyles(className, props, state, context)` resolves classes into React Native style objects.
- Cache keys include class names, component state, whether theme is scoped, and explicit layout direction context.
- Cache keys include class names, component state, whether theme is scoped, layout direction, and a key derived from the merged `ScopedVariables` map.
- During resolve, `ScopedVariables` overrides are overlaid onto a prototype-chained clone of the theme vars so unset variables fall through to the theme.
- 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.
Expand All @@ -78,6 +81,7 @@ Web runtime:
- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change.
- `ScopedTheme` renders a `div` with the theme class and `display: contents` on web.
- `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree.
- `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the hidden `dummyParent`, then clears them.
- Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element.

Shared runtime:
Expand All @@ -88,6 +92,7 @@ Shared runtime:
- `Uniwind.updateInsets(insets)` is native-only behavior and updates safe-area-style runtime values.
- `ScopedTheme` sets `UniwindContext.scopedTheme`; scoped subtree ignores global theme changes for style resolution.
- `LayoutDirection` sets `UniwindContext.rtl`; scoped subtree uses that direction for RTL/LTR variant resolution instead of global runtime RTL.
- `ScopedVariables` sets `UniwindContext.variables`; the subtree overrides CSS variables for style resolution and `useCSSVariable` without mutating the global theme. Nested providers merge with ancestors, nearest wins.

## Build And Bundler Model

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React, { useMemo } from 'react'
import { UniwindContext, useUniwindContext } from '../../core/context'
import type { UniwindContextType } from '../../core/types'
import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils'

export const ScopedVariables: React.FC<React.PropsWithChildren<ScopedVariablesProps>> = ({ variables, children }) => {
const uniwindContext = useUniwindContext()
const value = useMemo<UniwindContextType>(
() => buildScopedVariablesContext(uniwindContext, variables),
[uniwindContext, variables],
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return (
<UniwindContext.Provider value={value}>
{children}
</UniwindContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useMemo } from 'react'
import { UniwindContext, useUniwindContext } from '../../core/context'
import type { UniwindContextType } from '../../core/types'
import { toWebValue } from '../../core/web/webUtils'
import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils'

export const ScopedVariables: React.FC<React.PropsWithChildren<ScopedVariablesProps>> = ({ variables, children }) => {
const uniwindContext = useUniwindContext()
const value = useMemo<UniwindContextType>(
() => buildScopedVariablesContext(uniwindContext, variables),
[uniwindContext, variables],
)
// Inline custom properties so the DOM cascade resolves var(--name) for descendants
const style = useMemo<React.CSSProperties>(() => {
const result: Record<string, string | number> = { display: 'contents' }

Object.entries(variables).forEach(([name, variableValue]) => {
if (name.startsWith('--')) {
result[name] = toWebValue(variableValue)
}
})

return result as React.CSSProperties
}, [variables])

return (
<div style={style}>
<UniwindContext.Provider value={value}>
{children}
</UniwindContext.Provider>
</div>
)
}
1 change: 1 addition & 0 deletions packages/uniwind/src/components/ScopedVariables/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ScopedVariables'
41 changes: 41 additions & 0 deletions packages/uniwind/src/components/ScopedVariables/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Logger } from '../../core/logger'
import type { CSSVariables, UniwindContextType } from '../../core/types'

export type ScopedVariablesProps = {
variables: CSSVariables
}

const validateVariables = (variables: CSSVariables) =>
Object.fromEntries(
Object.entries(variables).filter(([name]) => {
if (!name.startsWith('--')) {
if (__DEV__) {
Logger.error(`CSS variable name must start with "--", instead got: ${name}`)
}

return false
}

return true
}),
)

export const buildScopedVariablesContext = (
parent: UniwindContextType,
variables: CSSVariables,
): UniwindContextType => {
// Merged with ancestors, nearest wins
const mergedVariables = {
...parent.variables,
...validateVariables(variables),
}
const variablesCacheKey = JSON.stringify(
Object.entries(mergedVariables).sort(([a], [b]) => a.localeCompare(b)),
)

return {
...parent,
variables: mergedVariables,
variablesCacheKey,
}
}
2 changes: 1 addition & 1 deletion packages/uniwind/src/core/config/config.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export class UniwindConfigBuilder {
}

getCSSVariable = ((variableName: string | Array<string>) => {
return getCSSVariable(variableName, { scopedTheme: null, rtl: null })
return getCSSVariable(variableName, { scopedTheme: null, rtl: null, variables: null, variablesCacheKey: null })
}) as GetCSSVariable

protected __reinit(_: GenerateStyleSheetsCallback, themes: Array<string>) {
Expand Down
20 changes: 2 additions & 18 deletions packages/uniwind/src/core/config/config.native.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { formatHex, formatHex8, parse } from 'culori'
import type { Insets } from 'react-native'
import { StyleDependency } from '../../common/consts'
import { UniwindListener } from '../listener'
import { Logger } from '../logger'
import { UniwindStore } from '../native'
import { createVarGetter } from '../native/native-utils'
import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../types'
import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common'

Expand All @@ -20,24 +20,8 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
return
}

const getValue = () => {
if (typeof varValue === 'number') {
return varValue
}

const parsedColor = parse(varValue)

if (parsedColor) {
return parsedColor.alpha === undefined || parsedColor.alpha === 1
? formatHex(parsedColor)
: formatHex8(parsedColor)
}

return varValue
}

UniwindStore.vars[theme] ??= {}
UniwindStore.vars[theme][varName] = getValue
UniwindStore.vars[theme][varName] = createVarGetter(varValue)
})

UniwindListener.notify([StyleDependency.Variables])
Expand Down
11 changes: 5 additions & 6 deletions packages/uniwind/src/core/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { arrayEquals } from '../../common/utils'
import { UniwindListener } from '../listener'
import { Logger } from '../logger'
import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../types'
import { getWebVariable } from '../web'
import { getWebVariable, toWebValue } from '../web'
import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common'

type UniwindCSSRule = {
Expand Down Expand Up @@ -42,15 +42,14 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
}

const existingRules: Record<ThemeName, string | undefined> = Object.fromEntries(
uniwindRules.map(rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme, rtl: null })]),
uniwindRules.map(
rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme, rtl: null, variables: null, variablesCacheKey: null })],
),
)

uniwindRules.forEach(rule => {
if (rule.theme === theme) {
rule.style.setProperty(
varName,
typeof varValue === 'number' ? `${varValue}px` : varValue,
)
rule.style.setProperty(varName, toWebValue(varValue))

return
}
Expand Down
4 changes: 3 additions & 1 deletion packages/uniwind/src/core/context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createContext, use } from 'react'
import type { ThemeName } from './types'
import type { CSSVariables, ThemeName } from './types'

export const UniwindContext = createContext({
scopedTheme: null as ThemeName | null,
rtl: null as boolean | null,
variables: null as CSSVariables | null,
variablesCacheKey: null as string | null,
})

export const useUniwindContext = () => use(UniwindContext)
Expand Down
19 changes: 18 additions & 1 deletion packages/uniwind/src/core/native/native-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { formatHex, formatHex8, interpolate, parse } from 'culori'
import type { UniwindRuntime } from '../types'
import type { UniwindRuntime, Var } from '../types'

// Normalizes a CSS variable value (numbers pass through, colors -> hex) and wraps it in a lazy getter
export const createVarGetter = (value: string | number): Var => () => {
if (typeof value === 'number') {
return value
}

const parsedColor = parse(value)

if (parsedColor) {
return parsedColor.alpha === undefined || parsedColor.alpha === 1
? formatHex(parsedColor)
: formatHex8(parsedColor)
}

return value
}

export const colorMix = (color: string, weight: number | string, mixColor: string) => {
const parsedWeight = typeof weight === 'string'
Expand Down
14 changes: 12 additions & 2 deletions packages/uniwind/src/core/native/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Dimensions, Platform } from 'react-native'
import { Orientation, Platform as UniwindPlatform, StyleDependency, UNIWIND_PLATFORM_VARIABLES, UNIWIND_THEME_VARIABLES } from '../../common/consts'
import { UniwindListener } from '../listener'
import type { ComponentState, GenerateStyleSheetsCallback, RNStyle, Style, StyleSheets, ThemeName, UniwindContextType, Var, Vars } from '../types'
import { createVarGetter } from './native-utils'
import { parseBoxShadow, parseFontVariant, parseTextShadowMutation, parseTransformsMutation, resolveGradient } from './parsers'
import { UniwindRuntime } from './runtime'

Expand Down Expand Up @@ -32,7 +33,7 @@ class UniwindStoreBuilder {
const isScopedTheme = uniwindContext.scopedTheme !== null
const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${
uniwindContext.rtl ?? ''
}`
}${uniwindContext.variablesCacheKey ?? ''}`
const cache = this.cache[uniwindContext.scopedTheme ?? this.runtime.currentThemeName]

if (!cache) {
Expand Down Expand Up @@ -101,7 +102,16 @@ class UniwindStoreBuilder {
const resultGetters = {} as Record<string, Var>
const theme = uniwindContext.scopedTheme ?? this.runtime.currentThemeName
// At this point we're sure that theme is correct
let vars = this.vars[theme]!
const themeVars = this.vars[theme]!
// Overlay scoped variables onto a prototype-chained clone so unset vars fall through to the theme
let vars = uniwindContext.variables === null
? themeVars
: Object.assign(
Object.create(themeVars) as Vars,
Object.fromEntries(
Object.entries(uniwindContext.variables).map(([name, value]) => [name, createVarGetter(value)]),
),
)
const originalVars = vars
let hasDataAttributes = false
const dependencies = new Set<StyleDependency>()
Expand Down
Loading