diff --git a/CONTEXT.md b/CONTEXT.md index e431f789..f5a5d439 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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: @@ -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. diff --git a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts index 3e5a7cd5..f7dc5d59 100644 --- a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts +++ b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts @@ -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), diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index 65c2314d..08b1483a 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -26,6 +26,8 @@ class UniwindStoreBuilder { vars = {} as Record private stylesheet = {} as StyleSheets private cache = {} as Record> + // Data attributes read by each class string with data variants, filled by resolveStyles + private dataAttributeNames = new Map>() getStyles( className: string | undefined, @@ -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] @@ -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) + UniwindListener.subscribe( + () => cache.delete(storeKey), + result.dependencies, + { once: true }, + ) return result } + private getDataAttributesCacheKey(className: string, componentProps: Record | undefined) { + const names = this.dataAttributeNames.get(className) + + return names === undefined ? '' : JSON.stringify(names.map(name => componentProps?.[name] ?? null)) + } + reinit = (generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) => { const config = generateStyleSheetCallback(this.runtime) const { scopedVars, stylesheet, vars } = config @@ -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() @@ -118,11 +128,11 @@ class UniwindStoreBuilder { getScopedVars(uniwindContext.variables), ) const originalVars = vars - let hasDataAttributes = false const dependencies = new Set() let dependencySum = 0 const bestRanks = new Map() const isScopedTheme = uniwindContext.scopedTheme !== null + let dataAttributeNames: Set | null = null const classNameTokens = classNames.includes('\n') ? classNames.split(/\s+/).filter(Boolean) : classNames.split(' ') @@ -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)) { @@ -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, } } diff --git a/packages/uniwind/src/core/types.ts b/packages/uniwind/src/core/types.ts index bcccba43..65d68129 100644 --- a/packages/uniwind/src/core/types.ts +++ b/packages/uniwind/src/core/types.ts @@ -15,7 +15,7 @@ export type Style = { state: ComponentState | undefined, vars: Vars, ) => boolean - hasDataAttributes: boolean + dataAttributeNames: Array | null dependencies: Array | null index: number className: string diff --git a/packages/uniwind/tests/native/core/data-attributes-cache.test.ts b/packages/uniwind/tests/native/core/data-attributes-cache.test.ts new file mode 100644 index 00000000..44bdef40 --- /dev/null +++ b/packages/uniwind/tests/native/core/data-attributes-cache.test.ts @@ -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) => + 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) + }) +}) diff --git a/packages/uniwind/tests/native/styles-parsing/matches.test.ts b/packages/uniwind/tests/native/styles-parsing/matches.test.ts index e5c83b76..df6cac05 100644 --- a/packages/uniwind/tests/native/styles-parsing/matches.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/matches.test.ts @@ -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', () => { @@ -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() }) })