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
6 changes: 3 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ 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, layout direction, and a key derived from the merged `ScopedVariables` map.
- Cache keys include class names, component state, whether theme is scoped, layout direction, and a key derived from the merged `ScopedVariables` map. Class strings with data variants also key on the values of the `data-*` props their rules read.
- 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 calls each rule's build-generated `matches(rt, context, props, state, vars)` predicate. Predicates contain only that rule's screen width/height, orientation, theme, RTL, active/focus/disabled, and `data-*` conditions, respecting scoped themes and layout direction. Data-dependent rules carry a boolean cache-bypass flag; condition metadata stays at build time.
- Native style resolution calls each rule's build-generated `matches(rt, context, props, state, vars)` predicate. Predicates contain only that rule's screen width/height, orientation, theme, RTL, active/focus/disabled, and `data-*` conditions, respecting scoped themes and layout direction. Data-dependent rules carry the names of the `data-*` attributes they read so cache keys can include those props; expected values and other condition metadata stay at build time.
- 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 @@ -147,7 +147,7 @@ Build-time stylesheet templates collect each rule as `{ style, meta }`: CSS decl

Important concepts:

- A runtime `Style` record stores entries, a generated `matches` function, a `hasDataAttributes` flag, dependencies, per-class index, class name, important properties, and normal and important cascade ranks (`complexity` and `importantComplexity`). Media/selector condition metadata stays in build-time templates. Platform conditions are filtered at build time and are not emitted as a runtime flag.
- A runtime `Style` record stores entries, a generated `matches` function, the `data-*` attribute names it reads (or `null`), dependencies, per-class index, class name, important properties, and normal and important cascade ranks (`complexity` and `importantComplexity`). Media/selector condition metadata stays in build-time templates. Platform conditions are filtered at build time and are not emitted as a runtime flag.
- Native property precedence is computed at build time from importance, cascade-layer priority, supported selector specificity, and compiled CSS order. Named layer statements, reopened layers, nested layers, anonymous layers, and unlayered rules participate; important declarations reverse layer priority. Every important rank is above every normal rank. Runtime resolution filters matching rules and compares the appropriate rank per property, independent of className token order or minimum-width priority.
- Specificity covers the selectors native already supports: classes, state pseudo-classes and data attributes contribute; `:where(...)` and media queries do not. Nested selectors inherit their parent's specificity. This is author-rule precedence for supported selectors, not a general DOM selector engine or support for inheritance, animations, transitions, `@scope`, or `revert-layer`.
- CSS variables live in `vars`; theme and platform-scoped variables live in `scopedVars` with internal prefixes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder) => {
return {
entries,
matches,
hasDataAttributes: dataAttributes !== null,
dataAttributeNames: dataAttributes !== null ? Object.keys(dataAttributes).map(makeSafeForSerialization) : null,
dependencies: dependencies.size > 0 ? Array.from(dependencies) : null,
index,
className: makeSafeForSerialization(className),
Expand Down
42 changes: 28 additions & 14 deletions packages/uniwind/src/core/native/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class UniwindStoreBuilder {
vars = {} as Record<ThemeName, Vars>
private stylesheet = {} as StyleSheets
private cache = {} as Record<ThemeName, Map<string, StylesResult>>
// Data attributes read by each class string with data variants, filled by resolveStyles
private dataAttributeNames = new Map<string, Array<string>>()

getStyles(
className: string | undefined,
Expand All @@ -38,7 +40,7 @@ class UniwindStoreBuilder {
}

const isScopedTheme = uniwindContext.scopedTheme !== null
const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${
const baseCacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${
uniwindContext.rtl ?? ''
}${uniwindContext.variables?.__uniwindVariablesCacheKey ?? ''}`
const cache = this.cache[uniwindContext.scopedTheme ?? this.runtime.currentThemeName]
Expand All @@ -47,25 +49,32 @@ class UniwindStoreBuilder {
return emptyState
}

const cacheKey = baseCacheKey + this.getDataAttributesCacheKey(className, componentProps)

if (cache.has(cacheKey)) {
return cache.get(cacheKey)!
}

const result = this.resolveStyles(className, componentProps, state, uniwindContext)
// The first resolve of a class string fills in its data attribute names, which changes its key
const storeKey = baseCacheKey + this.getDataAttributesCacheKey(className, componentProps)

// Don't cache styles that depend on data attributes
if (!result.hasDataAttributes) {
cache.set(cacheKey, result)
UniwindListener.subscribe(
() => cache.delete(cacheKey),
result.dependencies,
{ once: true },
)
}
cache.set(storeKey, result)
Comment thread
Brentlok marked this conversation as resolved.
Resolved
UniwindListener.subscribe(
() => cache.delete(storeKey),
result.dependencies,
{ once: true },
)

return result
}

private getDataAttributesCacheKey(className: string, componentProps: Record<string, any> | undefined) {
const names = this.dataAttributeNames.get(className)

return names === undefined ? '' : JSON.stringify(names.map(name => componentProps?.[name] ?? null))
Comment thread
Brentlok marked this conversation as resolved.
Resolved
}

reinit = (generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array<string>) => {
const config = generateStyleSheetCallback(this.runtime)
const { scopedVars, stylesheet, vars } = config
Expand Down Expand Up @@ -94,6 +103,7 @@ class UniwindStoreBuilder {
return [theme, clonedVars]
}))
this.cache = Object.fromEntries(themes.map(theme => [theme, new Map()]))
this.dataAttributeNames = new Map()

if (__DEV__) {
UniwindListener.notifyAll()
Expand All @@ -118,11 +128,11 @@ class UniwindStoreBuilder {
getScopedVars(uniwindContext.variables),
)
const originalVars = vars
let hasDataAttributes = false
const dependencies = new Set<StyleDependency>()
let dependencySum = 0
const bestRanks = new Map<string, number>()
const isScopedTheme = uniwindContext.scopedTheme !== null
let dataAttributeNames: Set<string> | null = null
const classNameTokens = classNames.includes('\n')
? classNames.split(/\s+/).filter(Boolean)
: classNames.split(' ')
Expand All @@ -144,8 +154,9 @@ class UniwindStoreBuilder {
})
}

if (style.hasDataAttributes) {
hasDataAttributes = true
if (style.dataAttributeNames !== null) {
dataAttributeNames ??= new Set()
style.dataAttributeNames.forEach(name => dataAttributeNames!.add(name))
}

if (!style.matches(this.runtime, uniwindContext, componentProps, state, vars)) {
Expand Down Expand Up @@ -222,11 +233,14 @@ class UniwindStoreBuilder {
parseTextShadowMutation(result)
}

if (dataAttributeNames !== null) {
this.dataAttributeNames.set(classNames, Array.from(dataAttributeNames))
}

return {
styles: result,
dependencies: Array.from(dependencies),
dependencySum,
hasDataAttributes,
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/uniwind/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type Style = {
state: ComponentState | undefined,
vars: Vars,
) => boolean
hasDataAttributes: boolean
dataAttributeNames: Array<string> | null
dependencies: Array<StyleDependency> | null
index: number
className: string
Expand Down
65 changes: 65 additions & 0 deletions packages/uniwind/tests/native/core/data-attributes-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { UniwindStore } from '../../../src/core/native/store'
import { TW_BLUE_500, TW_RED_500 } from '../../consts'

const context = { scopedTheme: null, rtl: null, variables: null }
const plain = 'bg-blue-500 opacity-50'
const withData = 'bg-blue-500 data-[active=true]:bg-red-500'
const withNumberLikeValue = 'bg-blue-500 data-[x=1]:bg-red-500'

const countResolves = (run: () => void) => {
const spy = jest.spyOn(UniwindStore as any, 'resolveStyles')

run()

const calls = spy.mock.calls.length

spy.mockRestore()

return calls
}

const getBackground = (className: string, props?: Record<string, any>) =>
UniwindStore.getStyles(className, props, undefined, context).styles.backgroundColor

describe('Caching styles that use data attributes', () => {
test('resolves a class string with a data variant only once for the same props', () => {
const calls = countResolves(() => {
for (let i = 0; i < 1000; i++) {
getBackground(withData, { 'data-active': true })
}
})

expect(calls).toBe(1)
})

test('returns the right style when a data attribute changes, and caches each value', () => {
const calls = countResolves(() => {
expect(getBackground(withData, { 'data-active': true })).toBe(TW_RED_500)
expect(getBackground(withData, { 'data-active': false })).toBe(TW_BLUE_500)
expect(getBackground(withData, undefined)).toBe(TW_BLUE_500)
expect(getBackground(withData, { 'data-active': 'true' })).toBe(TW_RED_500)
expect(getBackground(withData, { 'data-active': true })).toBe(TW_RED_500)
expect(getBackground(withData, { 'data-active': false })).toBe(TW_BLUE_500)
})

// One resolve each for true, false, missing and "true"; the repeats hit the cache
expect(calls).toBe(4)
})

test('keeps number and string values apart', () => {
expect(getBackground(withNumberLikeValue, { 'data-x': '1' })).toBe(TW_RED_500)
expect(getBackground(withNumberLikeValue, { 'data-x': 1 })).toBe(TW_BLUE_500)
expect(getBackground(withNumberLikeValue, { 'data-x': '1' })).toBe(TW_RED_500)
})

test('ignores data props that the class string does not use', () => {
const calls = countResolves(() => {
getBackground(withData, { 'data-active': true, 'data-testid': 'a' })
getBackground(withData, { 'data-active': true, 'data-testid': 'b' })
getBackground(plain, { 'data-testid': 'a' })
getBackground(plain, { 'data-testid': 'b' })
})

expect(calls).toBe(2)
})
})
4 changes: 2 additions & 2 deletions packages/uniwind/tests/native/styles-parsing/matches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ describe('Generated style match functions', () => {
expect(style.dependencies).toEqual(
expect.arrayContaining([StyleDependency.Theme, StyleDependency.Rtl, StyleDependency.Orientation, StyleDependency.Dimensions]),
)
expect(style.hasDataAttributes).toBe(true)
expect(style.dataAttributeNames).toEqual(['data-ready', 'data-mode'])
})

test('matches color scheme using the scoped theme and subscribes to theme changes', () => {
Expand Down Expand Up @@ -258,7 +258,7 @@ describe('Generated style match functions', () => {

expect(style.matches(UniwindStore.runtime, context, undefined, undefined, {})).toBe(true)
expect(style.dependencies).toBeNull()
expect(style.hasDataAttributes).toBe(false)
expect(style.dataAttributeNames).toBeNull()
})
})

Expand Down
Loading