From 7066b9d2a784591bd903078a9cca047da2664e02 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Wed, 1 Jul 2026 10:36:40 -0700 Subject: [PATCH 1/7] feat: add ScopedVariables component for per-subtree CSS variable overrides --- CONTEXT.md | 5 + .../ScopedVariables.native.tsx | 52 +++++ .../ScopedVariables/ScopedVariables.tsx | 51 +++++ .../src/components/ScopedVariables/index.ts | 1 + .../uniwind/src/core/config/config.common.ts | 2 +- .../uniwind/src/core/config/config.native.ts | 20 +- packages/uniwind/src/core/config/config.ts | 2 +- packages/uniwind/src/core/context.ts | 3 +- .../uniwind/src/core/native/native-utils.ts | 28 ++- packages/uniwind/src/core/native/store.ts | 26 ++- packages/uniwind/src/core/web/getWebStyles.ts | 29 +++ .../useCSSVariable/getVariableValue.native.ts | 21 +- packages/uniwind/src/index.ts | 1 + packages/uniwind/tests/consts.ts | 1 + .../uniwind/tests/e2e/getWebStyles.test.ts | 10 +- .../components/scoped-variables.test.tsx | 180 ++++++++++++++++++ packages/uniwind/tests/type-test/theme.ts | 6 +- .../web/components/scoped-variables.test.tsx | 82 ++++++++ 18 files changed, 485 insertions(+), 35 deletions(-) create mode 100644 packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx create mode 100644 packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx create mode 100644 packages/uniwind/src/components/ScopedVariables/index.ts create mode 100644 packages/uniwind/tests/native/components/scoped-variables.test.tsx create mode 100644 packages/uniwind/tests/web/components/scoped-variables.test.tsx diff --git a/CONTEXT.md b/CONTEXT.md index bdcfe76b..1371faaf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. @@ -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. @@ -66,6 +68,7 @@ Native runtime: - `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. +- Subtrees with `ScopedVariables` bypass the resolution cache entirely (scoped variables are overlaid onto a prototype-chained clone of the theme vars during resolve, so overrides and custom-property references still resolve); this avoids serializing the variable map into the cache key on every resolve and avoids per-subtree cache bloat. - 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. @@ -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` scoped variables are applied on the read path (`getWebStyles`/`getWebVariable`) as inline custom properties on the hidden `dummyParent`, computed, then cleared, so the DOM cascade resolves overrides without touching `#uniwind-dynamic-styles`. - Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element. Shared runtime: @@ -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`; scoped subtree overrides CSS variables for style resolution and `useCSSVariable` reads without mutating the global theme. Nested providers merge `{ ...ancestor, ...own }`, nearest wins. Keys must start with `--`; invalid keys log a dev error and are ignored. Values are `string | number` and reuse the same normalization as `updateCSSVariables` (numbers pass through on native / become px on web where CSSOM requires it; color strings are parsed to hex). All three scoping primitives spread the full parent context, so sibling fields (`scopedTheme`, `rtl`, `variables`) compose and inherit without clobbering each other. ## Build And Bundler Model diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx new file mode 100644 index 00000000..eea7ef60 --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx @@ -0,0 +1,52 @@ +import React, { useMemo } from 'react' +import { View } from 'react-native' +import { UniwindContext, useUniwindContext } from '../../core/context' +import { Logger } from '../../core/logger' +import type { CSSVariables, UniwindContextType } from '../../core/types' + +type ScopedVariablesProps = { + variables: CSSVariables +} + +const validateVariables = (variables: CSSVariables) => { + if (!__DEV__) { + return variables + } + + return Object.fromEntries( + Object.entries(variables).filter(([name]) => { + if (!name.startsWith('--')) { + Logger.error(`CSS variable name must start with "--", instead got: ${name}`) + + return false + } + + return true + }), + ) +} + +export const ScopedVariables: React.FC> = ({ variables, children }) => { + const uniwindContext = useUniwindContext() + const value = useMemo( + () => ({ + ...uniwindContext, + // Nested providers extend their ancestors: a deep child sees the + // merge of every ancestor's variables, with the nearest provider + // winning on conflicts. + variables: { + ...uniwindContext.variables, + ...validateVariables(variables), + }, + }), + [uniwindContext, variables], + ) + + return ( + + + {children} + + + ) +} diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx new file mode 100644 index 00000000..41123a73 --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react' +import { UniwindContext, useUniwindContext } from '../../core/context' +import { Logger } from '../../core/logger' +import type { CSSVariables, UniwindContextType } from '../../core/types' + +type ScopedVariablesProps = { + variables: CSSVariables +} + +const validateVariables = (variables: CSSVariables) => { + if (!__DEV__) { + return variables + } + + return Object.fromEntries( + Object.entries(variables).filter(([name]) => { + if (!name.startsWith('--')) { + Logger.error(`CSS variable name must start with "--", instead got: ${name}`) + + return false + } + + return true + }), + ) +} + +export const ScopedVariables: React.FC> = ({ variables, children }) => { + const uniwindContext = useUniwindContext() + const value = useMemo( + () => ({ + ...uniwindContext, + // Nested providers extend their ancestors: a deep child sees the + // merge of every ancestor's variables, with the nearest provider + // winning on conflicts. + variables: { + ...uniwindContext.variables, + ...validateVariables(variables), + }, + }), + [uniwindContext, variables], + ) + + return ( +
+ + {children} + +
+ ) +} diff --git a/packages/uniwind/src/components/ScopedVariables/index.ts b/packages/uniwind/src/components/ScopedVariables/index.ts new file mode 100644 index 00000000..24b67412 --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/index.ts @@ -0,0 +1 @@ +export * from './ScopedVariables' diff --git a/packages/uniwind/src/core/config/config.common.ts b/packages/uniwind/src/core/config/config.common.ts index 674d903d..1af006ee 100644 --- a/packages/uniwind/src/core/config/config.common.ts +++ b/packages/uniwind/src/core/config/config.common.ts @@ -110,7 +110,7 @@ export class UniwindConfigBuilder { } getCSSVariable = ((variableName: string | Array) => { - return getCSSVariable(variableName, { scopedTheme: null, rtl: null }) + return getCSSVariable(variableName, { scopedTheme: null, rtl: null, variables: null }) }) as GetCSSVariable protected __reinit(_: GenerateStyleSheetsCallback, themes: Array) { diff --git a/packages/uniwind/src/core/config/config.native.ts b/packages/uniwind/src/core/config/config.native.ts index 917e1d40..5e0832b5 100644 --- a/packages/uniwind/src/core/config/config.native.ts +++ b/packages/uniwind/src/core/config/config.native.ts @@ -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' @@ -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]) diff --git a/packages/uniwind/src/core/config/config.ts b/packages/uniwind/src/core/config/config.ts index 252dd2d7..5a11b679 100644 --- a/packages/uniwind/src/core/config/config.ts +++ b/packages/uniwind/src/core/config/config.ts @@ -42,7 +42,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { } const existingRules: Record = 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 })]), ) uniwindRules.forEach(rule => { diff --git a/packages/uniwind/src/core/context.ts b/packages/uniwind/src/core/context.ts index a62349d5..2d3af34d 100644 --- a/packages/uniwind/src/core/context.ts +++ b/packages/uniwind/src/core/context.ts @@ -1,9 +1,10 @@ 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, }) export const useUniwindContext = () => use(UniwindContext) diff --git a/packages/uniwind/src/core/native/native-utils.ts b/packages/uniwind/src/core/native/native-utils.ts index a29ba8d8..2d7a27bb 100644 --- a/packages/uniwind/src/core/native/native-utils.ts +++ b/packages/uniwind/src/core/native/native-utils.ts @@ -1,5 +1,31 @@ import { formatHex, formatHex8, interpolate, parse } from 'culori' -import type { UniwindRuntime } from '../types' +import type { UniwindRuntime, Var } from '../types' + +/** + * Normalizes a CSS variable value for the native runtime and wraps it in a + * lazy getter, matching the behavior of `Uniwind.updateCSSVariables`: + * - numbers are passed through as-is + * - color strings are parsed with culori and formatted as hex / hex8 + * - anything else is returned verbatim + * + * Getters keep lazy semantics so a value can depend on the current vars bag + * (e.g. custom property references) when resolved. + */ +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' diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index e2c676aa..824385d6 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -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' @@ -30,6 +31,11 @@ class UniwindStoreBuilder { } const isScopedTheme = uniwindContext.scopedTheme !== null + // Subtrees with scoped variables () bypass the cache + // entirely. Keying the cache on the variable map would require + // serializing it on every resolve (expensive) and would bloat the + // cache with per-subtree entries, so we recompute instead. + const hasScopedVariables = uniwindContext.variables !== null const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${ uniwindContext.rtl ?? '' }` @@ -39,14 +45,14 @@ class UniwindStoreBuilder { return emptyState } - if (cache.has(cacheKey)) { + if (!hasScopedVariables && cache.has(cacheKey)) { return cache.get(cacheKey)! } const result = this.resolveStyles(className, componentProps, state, uniwindContext) - // Don't cache styles that depend on data attributes - if (!result.hasDataAttributes) { + // Don't cache styles that depend on data attributes or scoped variables + if (!hasScopedVariables && !result.hasDataAttributes) { cache.set(cacheKey, result) UniwindListener.subscribe( () => cache.delete(cacheKey), @@ -101,7 +107,19 @@ class UniwindStoreBuilder { const resultGetters = {} as Record 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 (from ) onto the theme vars. + // We clone via the prototype chain so theme defaults still resolve for + // any variable the subtree didn't override, and so getters that read + // other variables (custom property references) see the overrides too. + 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() diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index c4510bf1..97dca83c 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -17,6 +17,28 @@ if (dummyParent && dummy) { dummyParent.appendChild(dummy) } +/** + * Applies scoped CSS variables (from ) to `dummyParent` as + * inline custom properties so they cascade to `dummy` during style computation. + * Numbers are converted to px, matching `Uniwind.updateCSSVariables` on web. + * Returns a disposer that removes the applied properties again. + */ +const applyScopedVariables = (uniwindContext: UniwindContextType) => { + if (!dummyParent || uniwindContext.variables === null) { + return () => {} + } + + const names = Object.keys(uniwindContext.variables) + + Object.entries(uniwindContext.variables).forEach(([name, value]) => { + dummyParent.style.setProperty(name, typeof value === 'number' ? `${value}px` : value) + }) + + return () => { + names.forEach(name => dummyParent.style.removeProperty(name)) + } +} + const getActiveStylesForClass = (className: string) => { const extractedStyles = {} as Record @@ -78,6 +100,8 @@ export const getWebStyles = ( dummyParent?.removeAttribute('dir') } + const disposeScopedVariables = applyScopedVariables(uniwindContext) + dummy.className = className const dataSet = generateDataSet(componentProps ?? {}) @@ -96,6 +120,8 @@ export const getWebStyles = ( delete dummy.dataset[key] }) + disposeScopedVariables() + return Object.fromEntries( Object.entries(computedStyles) .map(([key, value]) => { @@ -128,7 +154,10 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType) dummyParent.removeAttribute('dir') } + const disposeScopedVariables = applyScopedVariables(uniwindContext) const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) + disposeScopedVariables() + return parseCSSValue(variable) } diff --git a/packages/uniwind/src/hooks/useCSSVariable/getVariableValue.native.ts b/packages/uniwind/src/hooks/useCSSVariable/getVariableValue.native.ts index 1db6a95d..d3bbf258 100644 --- a/packages/uniwind/src/hooks/useCSSVariable/getVariableValue.native.ts +++ b/packages/uniwind/src/hooks/useCSSVariable/getVariableValue.native.ts @@ -1,8 +1,23 @@ import { UniwindRuntime, UniwindStore } from '../../core/native' -import type { UniwindContextType } from '../../core/types' +import { createVarGetter } from '../../core/native/native-utils' +import type { UniwindContextType, Vars } from '../../core/types' export const getVariableValue = (name: string, uniwindContext: UniwindContextType) => { - const vars = UniwindStore.vars[uniwindContext.scopedTheme ?? UniwindRuntime.currentThemeName] + const themeVars = UniwindStore.vars[uniwindContext.scopedTheme ?? UniwindRuntime.currentThemeName] - return vars?.[name]?.(vars) + if (!themeVars) { + return undefined + } + + // Overlay scoped variables from on top of theme vars. + const vars: Vars = uniwindContext.variables === null + ? themeVars + : Object.assign( + Object.create(themeVars) as Vars, + Object.fromEntries( + Object.entries(uniwindContext.variables).map(([varName, value]) => [varName, createVarGetter(value)]), + ), + ) + + return vars[name]?.(vars) } diff --git a/packages/uniwind/src/index.ts b/packages/uniwind/src/index.ts index 9981080c..a0293b15 100644 --- a/packages/uniwind/src/index.ts +++ b/packages/uniwind/src/index.ts @@ -1,5 +1,6 @@ export * from './components/LayoutDirection' export * from './components/ScopedTheme' +export * from './components/ScopedVariables' export { Uniwind } from './core' export type { ThemeName, UniwindConfig } from './core/types' export { withUniwind } from './hoc' diff --git a/packages/uniwind/tests/consts.ts b/packages/uniwind/tests/consts.ts index 49586211..9041cf35 100644 --- a/packages/uniwind/tests/consts.ts +++ b/packages/uniwind/tests/consts.ts @@ -17,4 +17,5 @@ export const SCREEN_HEIGHT = 844 export const UNIWIND_CONTEXT_MOCK = { scopedTheme: null, rtl: null, + variables: null, } satisfies UniwindContextType diff --git a/packages/uniwind/tests/e2e/getWebStyles.test.ts b/packages/uniwind/tests/e2e/getWebStyles.test.ts index 88165a22..b29d82ba 100644 --- a/packages/uniwind/tests/e2e/getWebStyles.test.ts +++ b/packages/uniwind/tests/e2e/getWebStyles.test.ts @@ -15,7 +15,7 @@ const bundle = readFileSync(BUNDLE_PATH, 'utf-8') async function getWebStyles( page: import('@playwright/test').Page, className: string, - context: UniwindContextType = { scopedTheme: null, rtl: null }, + context: UniwindContextType = { scopedTheme: null, rtl: null, variables: null }, ) { return page.evaluate( ([cls, ctx]) => { @@ -53,24 +53,24 @@ test.describe('getWebStyles — basic cases', () => { test.describe('getWebStyles — scoped theme', () => { test('bg-background in dark theme → backgroundColor black', async ({ page }) => { - const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'dark', rtl: null }) + const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'dark', rtl: null, variables: null }) expect(styles.backgroundColor).toBe('#000000') }) test('bg-background in light theme → backgroundColor white', async ({ page }) => { - const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'light', rtl: null }) + const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'light', rtl: null, variables: null }) expect(styles.backgroundColor).toBe('#ffffff') }) }) test.describe('getWebStyles — layout direction', () => { test('rtl variant resolves inside rtl context', async ({ page }) => { - const styles = await getWebStyles(page, 'rtl:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: true }) + const styles = await getWebStyles(page, 'rtl:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: true, variables: null }) expect(styles.backgroundColor).toBe(TW_RED_500) }) test('ltr variant resolves inside ltr context', async ({ page }) => { - const styles = await getWebStyles(page, 'ltr:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: false }) + const styles = await getWebStyles(page, 'ltr:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: false, variables: null }) expect(styles.backgroundColor).toBe(TW_RED_500) }) }) diff --git a/packages/uniwind/tests/native/components/scoped-variables.test.tsx b/packages/uniwind/tests/native/components/scoped-variables.test.tsx new file mode 100644 index 00000000..90104fa4 --- /dev/null +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -0,0 +1,180 @@ +import { act } from '@testing-library/react-native' +import * as React from 'react' +import View from '../../../src/components/native/View' +import { ScopedTheme } from '../../../src/components/ScopedTheme/ScopedTheme.native' +import { ScopedVariables } from '../../../src/components/ScopedVariables/ScopedVariables.native' +import { Uniwind } from '../../../src/core' +import { useCSSVariable } from '../../../src/hooks/useCSSVariable' +import { renderUniwind } from '../utils' + +// Utility classes below reference custom properties so the Tailwind scanner +// generates them into the native stylesheet: +// text-(--color-primary) -> color: var(--color-primary) +// gap-(--gap) -> gap: var(--gap) +// bg-background -> background-color: var(--color-background) + +describe('ScopedVariables', () => { + afterEach(() => { + act(() => { + Uniwind.setTheme('light') + Uniwind.updateCSSVariables('light', { '--color-background': '#ffffff' }) + Uniwind.updateCSSVariables('dark', { '--color-background': '#000000' }) + }) + }) + + test('overrides apply inside the subtree, defaults apply outside', () => { + const { getStylesFromId } = renderUniwind( + + + + + + , + ) + + // Outside the provider the variable is not defined by the theme -> undefined + expect(getStylesFromId('outside').color).toBeUndefined() + // Inside the provider the scoped value applies (normalized to hex) + expect(getStylesFromId('inside').color).toEqual('#3b82f6') + }) + + test('nested providers inherit ancestors, nearest wins on conflict', () => { + const { getStylesFromId } = renderUniwind( + + + + + + , + ) + + // Outer sees its own values + expect(getStylesFromId('outer').color).toEqual('#3b82f6') + expect(getStylesFromId('outer').gap).toEqual(4) + + // Inner overrides --color-primary but inherits --gap from the ancestor + expect(getStylesFromId('inner').color).toEqual('#ff0000') + expect(getStylesFromId('inner').gap).toEqual(4) + }) + + test('number values are passed through without normalization', () => { + const { getStylesFromId } = renderUniwind( + + + , + ) + + expect(getStylesFromId('numeric').gap).toEqual(8) + }) + + test('color strings are normalized to hex on native', () => { + const { getStylesFromId } = renderUniwind( + + + + + + , + ) + + expect(getStylesFromId('rgb').color).toEqual('#ff0000') + expect(getStylesFromId('rgba').color).toEqual('#00ff0080') + }) + + test('useCSSVariable returns scoped value inside and falls back outside', () => { + const outside = jest.fn() + const inside = jest.fn() + + const Probe = (props: { test: jest.Mock }) => { + props.test(useCSSVariable('--color-background')) + + return null + } + + renderUniwind( + + + + + + , + ) + + // Outside -> theme default (light background) + expect(outside).toHaveBeenCalledWith('#ffffff') + // Inside -> scoped override + expect(inside).toHaveBeenCalledWith('#123456') + }) + + test('composes with ScopedTheme: base follows theme, scoped value stays pinned', () => { + const { getStylesFromId } = renderUniwind( + + + + + + + + + + + + , + ) + + expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff') + expect(getStylesFromId('pinned').backgroundColor).toEqual('#abcdef') + expect(getStylesFromId('scoped-dark').backgroundColor).toEqual('#000000') + expect(getStylesFromId('scoped-dark-pinned').backgroundColor).toEqual('#abcdef') + + act(() => { + Uniwind.setTheme('dark') + }) + + // Base follows the global theme switch, pinned subtree stays put + expect(getStylesFromId('base').backgroundColor).toEqual('#000000') + expect(getStylesFromId('pinned').backgroundColor).toEqual('#abcdef') + // ScopedTheme subtree ignores global theme; pinned override still wins + expect(getStylesFromId('scoped-dark').backgroundColor).toEqual('#000000') + expect(getStylesFromId('scoped-dark-pinned').backgroundColor).toEqual('#abcdef') + }) + + test('updating the variables prop re-renders descendants', () => { + const Wrapper = ({ color }: { color: string }) => ( + + + + ) + + const { getStylesFromId, rerender } = renderUniwind() + + expect(getStylesFromId('dynamic').color).toEqual('#3b82f6') + + act(() => { + rerender() + }) + + expect(getStylesFromId('dynamic').color).toEqual('#ff0000') + }) + + test('non "--" keys trigger a dev error and are ignored', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const { getStylesFromId } = renderUniwind( + // @ts-expect-error intentionally passing an invalid key for the dev warning + + + , + ) + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('CSS variable name must start with "--"'), + ) + // The valid variable still applies + expect(getStylesFromId('valid').gap).toEqual(8) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/packages/uniwind/tests/type-test/theme.ts b/packages/uniwind/tests/type-test/theme.ts index ef2eb6e1..da8df6f5 100644 --- a/packages/uniwind/tests/type-test/theme.ts +++ b/packages/uniwind/tests/type-test/theme.ts @@ -1,5 +1,5 @@ import type { ComponentProps } from 'react' -import { ScopedTheme, type ThemeName, Uniwind, useUniwind } from 'uniwind' +import { ScopedTheme, ScopedVariables, type ThemeName, Uniwind, useUniwind } from 'uniwind' import { type Equal, type Expect } from './checks' type ExpectedThemeName = 'light' | 'dark' | 'premium' | 'custom' @@ -28,3 +28,7 @@ type UniwindUpdateCSSVariablesThemeTest = Expect['theme'] type ScopedThemeThemePropTest = Expect> + +// ScopedVariables variables prop +type ScopedVariablesProp = ComponentProps['variables'] +type ScopedVariablesPropTest = Expect>> diff --git a/packages/uniwind/tests/web/components/scoped-variables.test.tsx b/packages/uniwind/tests/web/components/scoped-variables.test.tsx new file mode 100644 index 00000000..6bde2404 --- /dev/null +++ b/packages/uniwind/tests/web/components/scoped-variables.test.tsx @@ -0,0 +1,82 @@ +import { render } from '@testing-library/react' +import * as React from 'react' +import { ScopedVariables } from '../../../src' +import { getWebVariable } from '../../../src/core/web' +import { useCSSVariable } from '../../../src/hooks/useCSSVariable' + +describe('ScopedVariables (web)', () => { + test('scoped custom property resolves on a child, falls back outside', () => { + const outside = jest.fn() + const inside = jest.fn() + + const Probe = ({ test }: { test: jest.Mock }) => { + test(useCSSVariable('--color-primary')) + + return null + } + + render( + + + + + + , + ) + + // Not defined by the theme in the web test env -> empty string + expect(outside).toHaveBeenCalledWith('') + // Inside the provider the scoped value resolves through the DOM cascade + expect(inside).toHaveBeenCalledWith('#3b82f6') + }) + + test('nested providers inherit ancestors and the nearest wins', () => { + const values: Array> = [] + + const Probe = () => { + values.push({ + primary: useCSSVariable('--color-primary'), + gap: useCSSVariable('--gap'), + }) + + return null + } + + render( + + + + + + , + ) + + const [outer, inner] = values + + expect(outer).toEqual({ primary: '#3b82f6', gap: '8px' }) + // Inner overrides the color but inherits --gap from the ancestor + expect(inner).toEqual({ primary: '#ff0000', gap: '8px' }) + }) + + test('renders a display:contents wrapper around children', () => { + const { getByText } = render( + + scoped content + , + ) + + expect(getByText('scoped content').parentElement).toHaveStyle({ display: 'contents' }) + }) + + test('getWebVariable applies number values as px and clears them afterwards', () => { + expect( + getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: { '--gap': 8 } }), + ).toEqual('8px') + + // After resolving with scoped variables, the dummy parent no longer + // carries the property (it is cleared), so a plain read falls back. + expect( + getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: null }), + ).toEqual('') + }) +}) From 6fefdd7a7582f7cfdacb228b5d568d620cf68796 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Wed, 1 Jul 2026 11:08:54 -0700 Subject: [PATCH 2/7] feat: add opt-in cacheKey to ScopedVariables for native style caching --- CONTEXT.md | 8 +- .../ScopedVariables.native.tsx | 41 +----- .../ScopedVariables/ScopedVariables.tsx | 41 +----- .../src/components/ScopedVariables/utils.ts | 73 ++++++++++ .../uniwind/src/core/config/config.common.ts | 2 +- packages/uniwind/src/core/config/config.ts | 4 +- packages/uniwind/src/core/context.ts | 1 + packages/uniwind/src/core/native/store.ts | 20 +-- packages/uniwind/tests/consts.ts | 1 + .../uniwind/tests/e2e/getWebStyles.test.ts | 20 ++- .../components/scoped-variables.test.tsx | 136 ++++++++++++++++++ .../web/components/scoped-variables.test.tsx | 24 +++- 12 files changed, 277 insertions(+), 94 deletions(-) create mode 100644 packages/uniwind/src/components/ScopedVariables/utils.ts diff --git a/CONTEXT.md b/CONTEXT.md index 1371faaf..be6ea19a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -67,8 +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. -- Subtrees with `ScopedVariables` bypass the resolution cache entirely (scoped variables are overlaid onto a prototype-chained clone of the theme vars during resolve, so overrides and custom-property references still resolve); this avoids serializing the variable map into the cache key on every resolve and avoids per-subtree cache bloat. +- Cache keys include class names, component state, whether theme is scoped, layout direction, and the opt-in `ScopedVariables` cache key. +- `ScopedVariables` subtrees bypass the cache by default (variables are overlaid onto a prototype-chained clone of the theme vars during resolve); an opt-in `cacheKey` is folded into the cache key to restore caching. Caller owns key stability. - 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. @@ -81,7 +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` scoped variables are applied on the read path (`getWebStyles`/`getWebVariable`) as inline custom properties on the hidden `dummyParent`, computed, then cleared, so the DOM cascade resolves overrides without touching `#uniwind-dynamic-styles`. +- `ScopedVariables` applies its variables as inline custom properties on the hidden `dummyParent` during read, then clears them, so the DOM cascade resolves overrides. - Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element. Shared runtime: @@ -92,7 +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`; scoped subtree overrides CSS variables for style resolution and `useCSSVariable` reads without mutating the global theme. Nested providers merge `{ ...ancestor, ...own }`, nearest wins. Keys must start with `--`; invalid keys log a dev error and are ignored. Values are `string | number` and reuse the same normalization as `updateCSSVariables` (numbers pass through on native / become px on web where CSSOM requires it; color strings are parsed to hex). All three scoping primitives spread the full parent context, so sibling fields (`scopedTheme`, `rtl`, `variables`) compose and inherit without clobbering each other. +- `ScopedVariables` sets `UniwindContext.variables` (and optional `variablesCacheKey` via its `cacheKey` prop); scoped subtree overrides CSS variables for style resolution and `useCSSVariable` without mutating the global theme. Nested providers merge `{ ...ancestor, ...own }`, nearest wins; keys must start with `--` (invalid keys log a dev error); values reuse `updateCSSVariables` normalization. All scoping primitives spread the full parent context so sibling fields compose without clobbering. ## Build And Bundler Model diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx index eea7ef60..a4bbe1bf 100644 --- a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx @@ -1,45 +1,14 @@ import React, { useMemo } from 'react' import { View } from 'react-native' import { UniwindContext, useUniwindContext } from '../../core/context' -import { Logger } from '../../core/logger' -import type { CSSVariables, UniwindContextType } from '../../core/types' +import type { UniwindContextType } from '../../core/types' +import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils' -type ScopedVariablesProps = { - variables: CSSVariables -} - -const validateVariables = (variables: CSSVariables) => { - if (!__DEV__) { - return variables - } - - return Object.fromEntries( - Object.entries(variables).filter(([name]) => { - if (!name.startsWith('--')) { - Logger.error(`CSS variable name must start with "--", instead got: ${name}`) - - return false - } - - return true - }), - ) -} - -export const ScopedVariables: React.FC> = ({ variables, children }) => { +export const ScopedVariables: React.FC> = ({ variables, cacheKey, children }) => { const uniwindContext = useUniwindContext() const value = useMemo( - () => ({ - ...uniwindContext, - // Nested providers extend their ancestors: a deep child sees the - // merge of every ancestor's variables, with the nearest provider - // winning on conflicts. - variables: { - ...uniwindContext.variables, - ...validateVariables(variables), - }, - }), - [uniwindContext, variables], + () => buildScopedVariablesContext(uniwindContext, variables, cacheKey), + [uniwindContext, variables, cacheKey], ) return ( diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx index 41123a73..50c324a4 100644 --- a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx @@ -1,44 +1,13 @@ import React, { useMemo } from 'react' import { UniwindContext, useUniwindContext } from '../../core/context' -import { Logger } from '../../core/logger' -import type { CSSVariables, UniwindContextType } from '../../core/types' +import type { UniwindContextType } from '../../core/types' +import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils' -type ScopedVariablesProps = { - variables: CSSVariables -} - -const validateVariables = (variables: CSSVariables) => { - if (!__DEV__) { - return variables - } - - return Object.fromEntries( - Object.entries(variables).filter(([name]) => { - if (!name.startsWith('--')) { - Logger.error(`CSS variable name must start with "--", instead got: ${name}`) - - return false - } - - return true - }), - ) -} - -export const ScopedVariables: React.FC> = ({ variables, children }) => { +export const ScopedVariables: React.FC> = ({ variables, cacheKey, children }) => { const uniwindContext = useUniwindContext() const value = useMemo( - () => ({ - ...uniwindContext, - // Nested providers extend their ancestors: a deep child sees the - // merge of every ancestor's variables, with the nearest provider - // winning on conflicts. - variables: { - ...uniwindContext.variables, - ...validateVariables(variables), - }, - }), - [uniwindContext, variables], + () => buildScopedVariablesContext(uniwindContext, variables, cacheKey), + [uniwindContext, variables, cacheKey], ) return ( diff --git a/packages/uniwind/src/components/ScopedVariables/utils.ts b/packages/uniwind/src/components/ScopedVariables/utils.ts new file mode 100644 index 00000000..1b300f08 --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/utils.ts @@ -0,0 +1,73 @@ +import { Logger } from '../../core/logger' +import type { CSSVariables, UniwindContextType } from '../../core/types' + +export type ScopedVariablesProps = { + variables: CSSVariables + /** + * Opt-in native style caching for this subtree. + * + * By default a `` subtree bypasses the native style cache + * (styles are recomputed on every render) so overrides always take effect + * without serializing the variable map on every resolve. + * + * Pass a **stable** string that uniquely identifies this variable set and + * the native runtime will fold it into the style cache key and reuse cached + * results. You own the stability contract: if you pass the same `cacheKey` + * for different variable values, stale styles will be served. + * + * Caching only engages when every ancestor `` in the merge + * chain also opted in; if any ancestor bypasses, this subtree bypasses too. + */ + cacheKey?: string +} + +const validateVariables = (variables: CSSVariables) => { + if (!__DEV__) { + return variables + } + + return Object.fromEntries( + Object.entries(variables).filter(([name]) => { + if (!name.startsWith('--')) { + Logger.error(`CSS variable name must start with "--", instead got: ${name}`) + + return false + } + + return true + }), + ) +} + +/** + * Builds the context value for a `` provider. + * + * - Merges variables with ancestors: `{ ...inherited, ...own }`, nearest wins. + * - Derives `variablesCacheKey` for the native cache: + * - `null` (bypass) when no `cacheKey` is supplied, or when an ancestor with + * variables did not opt in (its variable set is not guaranteed stable). + * - otherwise a combined, collision-resistant key of ancestor + own keys. + */ +export const buildScopedVariablesContext = ( + parent: UniwindContextType, + variables: CSSVariables, + cacheKey: string | undefined, +): UniwindContextType => { + const ancestorHasVariables = parent.variables !== null + const ancestorIsCacheable = !ancestorHasVariables || parent.variablesCacheKey !== null + + // NULL separator can't appear in user-supplied keys, so ancestor + own + // keys combine without ambiguity. + const variablesCacheKey = cacheKey === undefined || !ancestorIsCacheable + ? null + : `${parent.variablesCacheKey ?? ''}\0${cacheKey}` + + return { + ...parent, + variables: { + ...parent.variables, + ...validateVariables(variables), + }, + variablesCacheKey, + } +} diff --git a/packages/uniwind/src/core/config/config.common.ts b/packages/uniwind/src/core/config/config.common.ts index 1af006ee..2d57de7e 100644 --- a/packages/uniwind/src/core/config/config.common.ts +++ b/packages/uniwind/src/core/config/config.common.ts @@ -110,7 +110,7 @@ export class UniwindConfigBuilder { } getCSSVariable = ((variableName: string | Array) => { - return getCSSVariable(variableName, { scopedTheme: null, rtl: null, variables: null }) + return getCSSVariable(variableName, { scopedTheme: null, rtl: null, variables: null, variablesCacheKey: null }) }) as GetCSSVariable protected __reinit(_: GenerateStyleSheetsCallback, themes: Array) { diff --git a/packages/uniwind/src/core/config/config.ts b/packages/uniwind/src/core/config/config.ts index 5a11b679..c5d06173 100644 --- a/packages/uniwind/src/core/config/config.ts +++ b/packages/uniwind/src/core/config/config.ts @@ -42,7 +42,9 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { } const existingRules: Record = Object.fromEntries( - uniwindRules.map(rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme, rtl: null, variables: null })]), + uniwindRules.map( + rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme, rtl: null, variables: null, variablesCacheKey: null })], + ), ) uniwindRules.forEach(rule => { diff --git a/packages/uniwind/src/core/context.ts b/packages/uniwind/src/core/context.ts index 2d3af34d..65154b9c 100644 --- a/packages/uniwind/src/core/context.ts +++ b/packages/uniwind/src/core/context.ts @@ -5,6 +5,7 @@ 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) diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index 824385d6..a089f69c 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -31,28 +31,30 @@ class UniwindStoreBuilder { } const isScopedTheme = uniwindContext.scopedTheme !== null - // Subtrees with scoped variables () bypass the cache - // entirely. Keying the cache on the variable map would require - // serializing it on every resolve (expensive) and would bloat the - // cache with per-subtree entries, so we recompute instead. - const hasScopedVariables = uniwindContext.variables !== null + // Subtrees with scoped variables () bypass the cache by + // default: keying the cache on the variable map would require + // serializing it on every resolve (expensive) and would bloat the cache + // with per-subtree entries, so we recompute instead. When the caller + // opts in via a stable `cacheKey`, `variablesCacheKey` is set and folded + // into the cache key below so the normal cache path is used. + const bypassCache = uniwindContext.variables !== null && uniwindContext.variablesCacheKey === 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) { return emptyState } - if (!hasScopedVariables && cache.has(cacheKey)) { + if (!bypassCache && cache.has(cacheKey)) { return cache.get(cacheKey)! } const result = this.resolveStyles(className, componentProps, state, uniwindContext) - // Don't cache styles that depend on data attributes or scoped variables - if (!hasScopedVariables && !result.hasDataAttributes) { + // Don't cache styles that depend on data attributes or that bypass the cache + if (!bypassCache && !result.hasDataAttributes) { cache.set(cacheKey, result) UniwindListener.subscribe( () => cache.delete(cacheKey), diff --git a/packages/uniwind/tests/consts.ts b/packages/uniwind/tests/consts.ts index 9041cf35..7243f911 100644 --- a/packages/uniwind/tests/consts.ts +++ b/packages/uniwind/tests/consts.ts @@ -18,4 +18,5 @@ export const UNIWIND_CONTEXT_MOCK = { scopedTheme: null, rtl: null, variables: null, + variablesCacheKey: null, } satisfies UniwindContextType diff --git a/packages/uniwind/tests/e2e/getWebStyles.test.ts b/packages/uniwind/tests/e2e/getWebStyles.test.ts index b29d82ba..790d502d 100644 --- a/packages/uniwind/tests/e2e/getWebStyles.test.ts +++ b/packages/uniwind/tests/e2e/getWebStyles.test.ts @@ -15,7 +15,7 @@ const bundle = readFileSync(BUNDLE_PATH, 'utf-8') async function getWebStyles( page: import('@playwright/test').Page, className: string, - context: UniwindContextType = { scopedTheme: null, rtl: null, variables: null }, + context: UniwindContextType = { scopedTheme: null, rtl: null, variables: null, variablesCacheKey: null }, ) { return page.evaluate( ([cls, ctx]) => { @@ -53,24 +53,34 @@ test.describe('getWebStyles — basic cases', () => { test.describe('getWebStyles — scoped theme', () => { test('bg-background in dark theme → backgroundColor black', async ({ page }) => { - const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'dark', rtl: null, variables: null }) + const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'dark', rtl: null, variables: null, variablesCacheKey: null }) expect(styles.backgroundColor).toBe('#000000') }) test('bg-background in light theme → backgroundColor white', async ({ page }) => { - const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'light', rtl: null, variables: null }) + const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'light', rtl: null, variables: null, variablesCacheKey: null }) expect(styles.backgroundColor).toBe('#ffffff') }) }) test.describe('getWebStyles — layout direction', () => { test('rtl variant resolves inside rtl context', async ({ page }) => { - const styles = await getWebStyles(page, 'rtl:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: true, variables: null }) + const styles = await getWebStyles(page, 'rtl:bg-red-500 bg-blue-500', { + scopedTheme: null, + rtl: true, + variables: null, + variablesCacheKey: null, + }) expect(styles.backgroundColor).toBe(TW_RED_500) }) test('ltr variant resolves inside ltr context', async ({ page }) => { - const styles = await getWebStyles(page, 'ltr:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: false, variables: null }) + const styles = await getWebStyles(page, 'ltr:bg-red-500 bg-blue-500', { + scopedTheme: null, + rtl: false, + variables: null, + variablesCacheKey: null, + }) expect(styles.backgroundColor).toBe(TW_RED_500) }) }) diff --git a/packages/uniwind/tests/native/components/scoped-variables.test.tsx b/packages/uniwind/tests/native/components/scoped-variables.test.tsx index 90104fa4..4653f011 100644 --- a/packages/uniwind/tests/native/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -4,6 +4,9 @@ import View from '../../../src/components/native/View' import { ScopedTheme } from '../../../src/components/ScopedTheme/ScopedTheme.native' import { ScopedVariables } from '../../../src/components/ScopedVariables/ScopedVariables.native' import { Uniwind } from '../../../src/core' +import { useUniwindContext } from '../../../src/core/context' +import { UniwindStore } from '../../../src/core/native' +import type { UniwindContextType } from '../../../src/core/types' import { useCSSVariable } from '../../../src/hooks/useCSSVariable' import { renderUniwind } from '../utils' @@ -177,4 +180,137 @@ describe('ScopedVariables', () => { errorSpy.mockRestore() } }) + + describe('cacheKey opt-in', () => { + // A cached resolve returns the SAME result object reference on a hit, + // while a bypass resolve returns a fresh object every time. We use that + // referential identity to observe caching directly. + const baseContext = { scopedTheme: null, rtl: null } as const + + test('opting in caches: identical resolves return the same result reference', () => { + const context: UniwindContextType = { + ...baseContext, + variables: { '--gap': 8 }, + variablesCacheKey: 'stable-key', + } + + const first = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) + const second = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) + + expect(first.styles.gap).toEqual(8) + expect(second).toBe(first) + }) + + test('different cacheKeys do not collide even with identical variables', () => { + const contextA: UniwindContextType = { + ...baseContext, + variables: { '--gap': 8 }, + variablesCacheKey: 'key-a', + } + const contextB: UniwindContextType = { + ...baseContext, + variables: { '--gap': 8 }, + variablesCacheKey: 'key-b', + } + + const a = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextA) + const b = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextB) + + // Distinct cache entries -> distinct references, both correct + expect(a).not.toBe(b) + expect(a.styles.gap).toEqual(8) + expect(b.styles.gap).toEqual(8) + }) + + test('default (no cacheKey) still bypasses: resolves are fresh each time', () => { + const context: UniwindContextType = { + ...baseContext, + variables: { '--gap': 8 }, + variablesCacheKey: null, + } + + const first = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) + const second = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) + + // Bypass -> recomputed each call (no shared reference), still correct + expect(second).not.toBe(first) + expect(first.styles.gap).toEqual(8) + expect(second.styles.gap).toEqual(8) + }) + + test('component-level: cached subtree still resolves correctly across re-renders', () => { + const Wrapper = ({ color }: { color: string }) => ( + + + + ) + + const { getStylesFromId, rerender } = renderUniwind() + + expect(getStylesFromId('cached').color).toEqual('#3b82f6') + + // Re-render with the same cacheKey and same value -> still correct + act(() => { + rerender() + }) + + expect(getStylesFromId('cached').color).toEqual('#3b82f6') + }) + + test('nested provider without cacheKey under a cached parent falls back to bypass', () => { + const Probe = (props: { test: jest.Mock }) => { + const { variablesCacheKey } = useUniwindContext() + + props.test(variablesCacheKey) + + return null + } + + const outer = jest.fn() + const innerCached = jest.fn() + const innerBypass = jest.fn() + + renderUniwind( + + + + + + + + + , + ) + + // Outer opted in (leading delimiter is an internal detail) + expect(outer).toHaveBeenCalledWith(expect.stringContaining('outer')) + // Nested opt-in composes the ancestor key so it can't collide + expect(innerCached).toHaveBeenCalledWith(expect.stringContaining('outer')) + expect(innerCached).toHaveBeenCalledWith(expect.stringContaining('inner')) + // Nested WITHOUT a key bypasses (null) even under a cached parent + expect(innerBypass).toHaveBeenCalledWith(null) + }) + + test('opt-in provider under a bypassing ancestor still bypasses', () => { + const Probe = (props: { test: jest.Mock }) => { + props.test(useUniwindContext().variablesCacheKey) + + return null + } + + const inner = jest.fn() + + renderUniwind( + + + + + , + ) + + // Ancestor did not opt in -> the merged variable set is not stable, + // so this subtree bypasses regardless of its own cacheKey. + expect(inner).toHaveBeenCalledWith(null) + }) + }) }) diff --git a/packages/uniwind/tests/web/components/scoped-variables.test.tsx b/packages/uniwind/tests/web/components/scoped-variables.test.tsx index 6bde2404..6b91222d 100644 --- a/packages/uniwind/tests/web/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/web/components/scoped-variables.test.tsx @@ -70,13 +70,33 @@ describe('ScopedVariables (web)', () => { test('getWebVariable applies number values as px and clears them afterwards', () => { expect( - getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: { '--gap': 8 } }), + getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: { '--gap': 8 }, variablesCacheKey: null }), ).toEqual('8px') // After resolving with scoped variables, the dummy parent no longer // carries the property (it is cleared), so a plain read falls back. expect( - getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: null }), + getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: null, variablesCacheKey: null }), ).toEqual('') }) + + test('cacheKey prop is accepted on web and does not change the resolved value', () => { + // The web read path has no memo cache, so cacheKey is a no-op there; + // it must still resolve scoped variables normally. + const inside = jest.fn() + + const Probe = ({ test }: { test: jest.Mock }) => { + test(useCSSVariable('--color-primary')) + + return null + } + + render( + + + , + ) + + expect(inside).toHaveBeenCalledWith('#3b82f6') + }) }) From 8cc0e72d46bbd18018bd4d6dbf0cd37970809490 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 17 Jul 2026 11:02:44 -0700 Subject: [PATCH 3/7] docs(expo-example): add ScopedVariables demo Demonstrate per-subtree CSS variable overrides in the expo example app: theme default, scoped override, nested inheritance (nearest wins), and the opt-in cacheKey. Adds --color-primary/--color-surface/--gap theme defaults so the unscoped baseline renders intentionally. --- apps/expo-example/App.tsx | 3 + apps/expo-example/ScopedVariablesDemo.tsx | 69 +++++++++++++++++++++++ apps/expo-example/global.css | 11 ++++ 3 files changed, 83 insertions(+) create mode 100644 apps/expo-example/ScopedVariablesDemo.tsx diff --git a/apps/expo-example/App.tsx b/apps/expo-example/App.tsx index b5539b3e..6df94220 100644 --- a/apps/expo-example/App.tsx +++ b/apps/expo-example/App.tsx @@ -1,5 +1,6 @@ import './global.css' import { ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native' +import { ScopedVariablesDemo } from './ScopedVariablesDemo' const TailwindTestPage = () => { return ( @@ -654,6 +655,8 @@ const TailwindTestPage = () => { + + ) } diff --git a/apps/expo-example/ScopedVariablesDemo.tsx b/apps/expo-example/ScopedVariablesDemo.tsx new file mode 100644 index 00000000..54526f92 --- /dev/null +++ b/apps/expo-example/ScopedVariablesDemo.tsx @@ -0,0 +1,69 @@ +import { Text, View } from 'react-native' +import { ScopedVariables, useCSSVariable } from 'uniwind' + +// Reads a variable through the runtime and prints its resolved value. Inside a +// subtree it reflects the scoped override; outside it falls +// back to the theme default from global.css. +const VariableReadout = ({ name }: { name: string }) => { + const value = useCSSVariable(name) + + return ( + + {name} = {value === undefined ? '(unset)' : String(value)} + + ) +} + +// A card whose accent color and inner spacing come entirely from CSS variables. +// The same markup renders differently depending on the surrounding scope. +const AccentCard = ({ label }: { label: string }) => ( + + + {label} + + Accent text uses --color-primary + + + +) + +export const ScopedVariablesDemo = () => { + return ( + + Scoped Variables + + {/* 1. Theme defaults — no provider, values come from global.css */} + Theme default + + + + + {/* 2. Scoped override — same markup, different accent + spacing */} + Scoped override + + + + + + + {/* 3. Nested providers — inner overrides color, inherits --gap */} + Nested (nearest wins) + + + + + + + + + + {/* 4. Opt-in native caching via a stable cacheKey */} + Cached subtree (cacheKey) + + + + + + + ) +} diff --git a/apps/expo-example/global.css b/apps/expo-example/global.css index 6011adf8..ce789caf 100644 --- a/apps/expo-example/global.css +++ b/apps/expo-example/global.css @@ -1,6 +1,17 @@ @import 'tailwindcss'; @import 'uniwind'; +/* Theme defaults consumed by the ScopedVariables demo. Outside any + subtree these are the values that apply. */ +@theme { + --color-primary: #2b7fff; + --color-surface: #ffffff; +} + +:root { + --gap: 8px; +} + @layer theme { :root { @variant dark { From 232702ed4e3690a113bd8f48c7d68a4521eda31d Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 17 Jul 2026 11:31:08 -0700 Subject: [PATCH 4/7] fix(ScopedVariables): apply scoped variables to the web DOM cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On web, Uniwind passes classes through RNW unchanged, so real elements resolve `var(--name)` from the live CSS cascade. The wrapper only applied its variables to the hidden dummyParent used for JS reads, so scoped overrides never reached descendants — styling stayed at the theme default while useCSSVariable readouts (which use the dummyParent path) looked correct. Set the variables as inline custom properties on the display:contents wrapper so they cascade to children (numbers -> px). Add web regression tests asserting the wrapper carries the overrides inline, nested wrappers only declare their own overrides, and invalid keys are dropped. Rework the expo-example demo with origin pills (default/set here/inherited), swatches, and a gap strip so the override/inherit story is legible. --- CONTEXT.md | 2 +- apps/expo-example/ScopedVariablesDemo.tsx | 137 +++++++++++++----- .../ScopedVariables/ScopedVariables.tsx | 21 ++- .../web/components/scoped-variables.test.tsx | 49 +++++++ 4 files changed, 171 insertions(+), 38 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index be6ea19a..7d67cb72 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -81,7 +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` applies its variables as inline custom properties on the hidden `dummyParent` during read, then clears them, so the DOM cascade resolves overrides. +- `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: diff --git a/apps/expo-example/ScopedVariablesDemo.tsx b/apps/expo-example/ScopedVariablesDemo.tsx index 54526f92..08062bda 100644 --- a/apps/expo-example/ScopedVariablesDemo.tsx +++ b/apps/expo-example/ScopedVariablesDemo.tsx @@ -1,68 +1,133 @@ import { Text, View } from 'react-native' import { ScopedVariables, useCSSVariable } from 'uniwind' -// Reads a variable through the runtime and prints its resolved value. Inside a -// subtree it reflects the scoped override; outside it falls -// back to the theme default from global.css. -const VariableReadout = ({ name }: { name: string }) => { +// Where a variable's value comes from at the point it is read: +// - default: the theme value from global.css (no provider overrode it) +// - override: set by the nearest wrapping this card +// - inherited: set by an ancestor provider and cascaded down unchanged +type Origin = 'default' | 'override' | 'inherited' + +const ORIGIN_META: Record = { + default: { label: 'theme default', pill: 'bg-gray-200', text: 'text-gray-700' }, + override: { label: 'set here', pill: 'bg-green-200', text: 'text-green-900' }, + inherited: { label: 'inherited', pill: 'bg-blue-200', text: 'text-blue-900' }, +} + +const OriginPill = ({ origin }: { origin: Origin }) => { + const meta = ORIGIN_META[origin] + + return ( + + {meta.label} + + ) +} + +// One variable, its resolved value, a swatch (for colors), and an origin pill. +const VarRow = ({ name, origin }: { name: string; origin: Origin }) => { const value = useCSSVariable(name) + const isColor = typeof value === 'string' && value.startsWith('#') return ( - - {name} = {value === undefined ? '(unset)' : String(value)} - + + {isColor + ? + : ( + + # + + )} + + {name} = {value === undefined ? '(unset)' : String(value)} + + + ) } -// A card whose accent color and inner spacing come entirely from CSS variables. -// The same markup renders differently depending on the surrounding scope. -const AccentCard = ({ label }: { label: string }) => ( - - - {label} +// A row of blocks separated by gap-(--gap). The space between the blocks IS +// the --gap value, so the difference between cards is directly visible. +const GapStrip = () => ( + + + + + + ← space between = --gap + +) + +// The card's header bar uses --color-primary (so you SEE the accent), and the +// GapStrip visualizes --gap as the space between blocks (so you SEE it change). +const AccentCard = ({ title, primary, gap }: { title: string; primary: Origin; gap: Origin }) => ( + + + {title} - Accent text uses --color-primary - - + + + + + + +) + +const SectionHeader = ({ title, subtitle }: { title: string; subtitle: string }) => ( + + {title} + {subtitle} ) export const ScopedVariablesDemo = () => { return ( - Scoped Variables + Scoped Variables + + Every card below is the same component. Only the wrapping {''}{' '} + changes the values it reads. + - {/* 1. Theme defaults — no provider, values come from global.css */} - Theme default - - + {/* Legend explaining the origin pills */} + + Value origin: + + + - {/* 2. Scoped override — same markup, different accent + spacing */} - Scoped override + {/* 1. No provider — values come straight from global.css */} + + + + {/* 2. One provider overriding both variables */} + - - - + - {/* 3. Nested providers — inner overrides color, inherits --gap */} - Nested (nearest wins) + {/* 3. Nested — inner sits INSIDE outer, overrides color, inherits gap */} + - - + + + ↳ nested inside “Outer card” - + - {/* 4. Opt-in native caching via a stable cacheKey */} - Cached subtree (cacheKey) + {/* 4. Same as override, plus an opt-in stable cacheKey (native caching) */} + - - - + ) diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx index 50c324a4..1398f047 100644 --- a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx @@ -10,8 +10,27 @@ export const ScopedVariables: React.FC(() => { + const result: Record = { display: 'contents' } + + for (const [name, variableValue] of Object.entries(variables)) { + if (!name.startsWith('--')) { + continue + } + + result[name] = typeof variableValue === 'number' ? `${variableValue}px` : variableValue + } + + return result as React.CSSProperties + }, [variables]) + return ( -
+
{children} diff --git a/packages/uniwind/tests/web/components/scoped-variables.test.tsx b/packages/uniwind/tests/web/components/scoped-variables.test.tsx index 6b91222d..1d8dc50c 100644 --- a/packages/uniwind/tests/web/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/web/components/scoped-variables.test.tsx @@ -68,6 +68,55 @@ describe('ScopedVariables (web)', () => { expect(getByText('scoped content').parentElement).toHaveStyle({ display: 'contents' }) }) + test('applies the variables as inline custom properties on the wrapper', () => { + // The real DOM cascade (RNW passes classes through as-is) resolves + // `var(--name)` against these inline properties, so they must land on + // the wrapper element itself, not only on the hidden read helper. + const { getByText } = render( + + scoped content + , + ) + + const wrapper = getByText('scoped content').parentElement! + + expect(wrapper.style.getPropertyValue('--color-primary')).toEqual('#3b82f6') + // Numbers are converted to px, matching updateCSSVariables on web + expect(wrapper.style.getPropertyValue('--gap')).toEqual('8px') + }) + + test('nested wrappers each carry only their own overrides inline', () => { + // Inheritance comes from the DOM cascade, so the inner wrapper only + // needs to declare what it overrides; --gap keeps cascading from outer. + const { getByText } = render( + + + inner content + + , + ) + + const innerWrapper = getByText('inner content').parentElement! + + expect(innerWrapper.style.getPropertyValue('--color-primary')).toEqual('#ff0000') + // Inner does not redeclare --gap; it inherits through the cascade + expect(innerWrapper.style.getPropertyValue('--gap')).toEqual('') + }) + + test('invalid keys are not written to the wrapper inline styles', () => { + const { getByText } = render( + // @ts-expect-error intentionally passing an invalid key + + scoped content + , + ) + + const wrapper = getByText('scoped content').parentElement! + + expect(wrapper.style.getPropertyValue('color-primary')).toEqual('') + expect(wrapper.style.getPropertyValue('--gap')).toEqual('8px') + }) + test('getWebVariable applies number values as px and clears them afterwards', () => { expect( getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: { '--gap': 8 }, variablesCacheKey: null }), From c34ed836c5d4e3f99b9a154f584ec9ba12cfd2fd Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Wed, 22 Jul 2026 15:01:46 -0700 Subject: [PATCH 5/7] fix(ScopedVariables): address review feedback on PR #611 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCSSVariable: recompute on context change, not only on global Theme/Variables events — an updated variables prop (or a nearer provider) now surfaces the new value. Adds web + native regression tests for a prop update. - utils: always drop non-`--` keys (not just in dev), gating only the Logger.error behind __DEV__, so invalid keys can't reach the web read helper and corrupt a resolved inheritable property in production. - getWebStyles/getWebVariable: wrap the scoped-variable read in try/finally so the temporary custom properties are always cleared, even if a DOM read throws. - Document the variables prop stability contract (define outside render or useMemo) in JSDoc. - Remove two unused @ts-expect-error directives in tests. - Reword the demo's cacheKey section so it no longer claims parity with section 2. --- apps/expo-example/ScopedVariablesDemo.tsx | 4 +- .../src/components/ScopedVariables/utils.ts | 27 +++++-- packages/uniwind/src/core/web/getWebStyles.ts | 73 ++++++++++--------- .../hooks/useCSSVariable/useCSSVariable.ts | 4 + .../components/scoped-variables.test.tsx | 27 ++++++- .../web/components/scoped-variables.test.tsx | 25 ++++++- 6 files changed, 115 insertions(+), 45 deletions(-) diff --git a/apps/expo-example/ScopedVariablesDemo.tsx b/apps/expo-example/ScopedVariablesDemo.tsx index 08062bda..77879aec 100644 --- a/apps/expo-example/ScopedVariablesDemo.tsx +++ b/apps/expo-example/ScopedVariablesDemo.tsx @@ -121,10 +121,10 @@ export const ScopedVariablesDemo = () => { - {/* 4. Same as override, plus an opt-in stable cacheKey (native caching) */} + {/* 4. A scoped override plus an opt-in stable cacheKey (native caching) */} diff --git a/packages/uniwind/src/components/ScopedVariables/utils.ts b/packages/uniwind/src/components/ScopedVariables/utils.ts index 1b300f08..8201cad2 100644 --- a/packages/uniwind/src/components/ScopedVariables/utils.ts +++ b/packages/uniwind/src/components/ScopedVariables/utils.ts @@ -2,6 +2,16 @@ import { Logger } from '../../core/logger' import type { CSSVariables, UniwindContextType } from '../../core/types' export type ScopedVariablesProps = { + /** + * CSS variables to override for this subtree. Keys must start with `--`; + * values are `string | number` (numbers become px on web, colors are + * normalized on native). + * + * Prefer a **stable** reference — define this object outside render or wrap + * it in `useMemo`. An inline object is a new reference every render, which + * recomputes the provider value and re-renders every consumer in the + * subtree (the same guidance as any React context provider value). + */ variables: CSSVariables /** * Opt-in native style caching for this subtree. @@ -21,15 +31,17 @@ export type ScopedVariablesProps = { cacheKey?: string } -const validateVariables = (variables: CSSVariables) => { - if (!__DEV__) { - return variables - } - - return Object.fromEntries( +// Drop keys that aren't custom properties in every build, not just dev: an +// invalid key left in `context.variables` reaches `applyScopedVariables`, which +// would set an inheritable property (e.g. `color`) on the web read helper and +// could corrupt a resolved value. Only the dev warning is gated on `__DEV__`. +const validateVariables = (variables: CSSVariables) => + Object.fromEntries( Object.entries(variables).filter(([name]) => { if (!name.startsWith('--')) { - Logger.error(`CSS variable name must start with "--", instead got: ${name}`) + if (__DEV__) { + Logger.error(`CSS variable name must start with "--", instead got: ${name}`) + } return false } @@ -37,7 +49,6 @@ const validateVariables = (variables: CSSVariables) => { return true }), ) -} /** * Builds the context value for a `` provider. diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index 97dca83c..e67cca86 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -102,39 +102,43 @@ export const getWebStyles = ( const disposeScopedVariables = applyScopedVariables(uniwindContext) - dummy.className = className + // finally: the scoped custom properties must be cleared even if a DOM read + // throws, otherwise `dummyParent` keeps them and the next resolve is stale. + try { + dummy.className = className - const dataSet = generateDataSet(componentProps ?? {}) + const dataSet = generateDataSet(componentProps ?? {}) - Object.entries(dataSet).forEach(([key, value]) => { - if (value === false || value === undefined) { - return - } - - dummy.dataset[key] = String(value) - }) - - const computedStyles = getActiveStylesForClass(className) - - Object.keys(dataSet).forEach(key => { - delete dummy.dataset[key] - }) + Object.entries(dataSet).forEach(([key, value]) => { + if (value === false || value === undefined) { + return + } - disposeScopedVariables() - - return Object.fromEntries( - Object.entries(computedStyles) - .map(([key, value]) => { - const parsedKey = key[0] === '-' - ? key - : key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) - - return [ - parsedKey, - parseCSSValue(value), - ] - }), - ) + dummy.dataset[key] = String(value) + }) + + const computedStyles = getActiveStylesForClass(className) + + Object.keys(dataSet).forEach(key => { + delete dummy.dataset[key] + }) + + return Object.fromEntries( + Object.entries(computedStyles) + .map(([key, value]) => { + const parsedKey = key[0] === '-' + ? key + : key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + + return [ + parsedKey, + parseCSSValue(value), + ] + }), + ) + } finally { + disposeScopedVariables() + } } export const getWebVariable = (name: string, uniwindContext: UniwindContextType) => { @@ -155,9 +159,12 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType) } const disposeScopedVariables = applyScopedVariables(uniwindContext) - const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) - disposeScopedVariables() + try { + const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) - return parseCSSValue(variable) + return parseCSSValue(variable) + } finally { + disposeScopedVariables() + } } diff --git a/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts index dcae35b5..2f890cf0 100644 --- a/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts +++ b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts @@ -82,6 +82,10 @@ export const useCSSVariable: GetCSSVariable = (name: string | Array) => useLayoutEffect(() => { const updateValue = () => setValue(getCSSVariable(nameRef.current, uniwindContext)) + // Recompute on context change too: a new `uniwindContext` (e.g. an + // updated `` prop or a nearer provider) can resolve to + // a different value without any global Theme/Variables event firing. + updateValue() const dispose = UniwindListener.subscribe( updateValue, [StyleDependency.Theme, StyleDependency.Variables], diff --git a/packages/uniwind/tests/native/components/scoped-variables.test.tsx b/packages/uniwind/tests/native/components/scoped-variables.test.tsx index 4653f011..39bb545c 100644 --- a/packages/uniwind/tests/native/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -109,6 +109,32 @@ describe('ScopedVariables', () => { expect(inside).toHaveBeenCalledWith('#123456') }) + test('useCSSVariable reflects an updated variables prop', () => { + const seen: Array = [] + + const Probe = () => { + seen.push(useCSSVariable('--color-primary')) + + return null + } + + const Wrapper = ({ color }: { color: string }) => ( + + + + ) + + const { rerender } = renderUniwind() + + act(() => { + rerender() + }) + + // Mounts with the initial value, then reflects the updated prop + expect(seen[0]).toEqual('#3b82f6') + expect(seen.at(-1)).toEqual('#ff0000') + }) + test('composes with ScopedTheme: base follows theme, scoped value stays pinned', () => { const { getStylesFromId } = renderUniwind( @@ -165,7 +191,6 @@ describe('ScopedVariables', () => { try { const { getStylesFromId } = renderUniwind( - // @ts-expect-error intentionally passing an invalid key for the dev warning , diff --git a/packages/uniwind/tests/web/components/scoped-variables.test.tsx b/packages/uniwind/tests/web/components/scoped-variables.test.tsx index 1d8dc50c..05f1fda5 100644 --- a/packages/uniwind/tests/web/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/web/components/scoped-variables.test.tsx @@ -30,6 +30,30 @@ describe('ScopedVariables (web)', () => { expect(inside).toHaveBeenCalledWith('#3b82f6') }) + test('useCSSVariable reflects an updated variables prop through the cascade', () => { + const seen: Array = [] + + const Probe = () => { + seen.push(useCSSVariable('--color-primary')) + + return null + } + + const Wrapper = ({ color }: { color: string }) => ( + + + + ) + + const { rerender } = render() + + rerender() + + // Mounts with the initial value, then reflects the updated prop + expect(seen[0]).toEqual('#3b82f6') + expect(seen.at(-1)).toEqual('#ff0000') + }) + test('nested providers inherit ancestors and the nearest wins', () => { const values: Array> = [] @@ -105,7 +129,6 @@ describe('ScopedVariables (web)', () => { test('invalid keys are not written to the wrapper inline styles', () => { const { getByText } = render( - // @ts-expect-error intentionally passing an invalid key scoped content , From d21795e3581dd1d3d36d25ae0dfdf797d1179933 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Thu, 23 Jul 2026 09:47:48 -0700 Subject: [PATCH 6/7] refactor(ScopedVariables): address maintainer review on PR #611 - Derive the native style cache key from the merged variables map instead of a user-supplied cacheKey prop; removes the cache-bypass path and the stale-key footgun - Drop the display: contents View wrapper on native, render the bare provider like ScopedTheme.native - Remove the expo-example demo (playground only, covered by tests) - useCSSVariable: skip the mount-time recompute, useState already resolved the initial value - Extract toWebValue (number -> px) web util, reuse in config, getWebStyles and the web wrapper - Remove the ScopedVariables prop type test - Trim long comments to match repo style --- CONTEXT.md | 6 +- apps/expo-example/App.tsx | 3 - apps/expo-example/ScopedVariablesDemo.tsx | 134 ------------------ apps/expo-example/global.css | 11 -- .../ScopedVariables.native.tsx | 15 +- .../ScopedVariables/ScopedVariables.tsx | 24 ++-- .../src/components/ScopedVariables/utils.ts | 62 ++------ packages/uniwind/src/core/config/config.ts | 7 +- .../uniwind/src/core/native/native-utils.ts | 11 +- packages/uniwind/src/core/native/store.ts | 18 +-- packages/uniwind/src/core/web/getWebStyles.ts | 13 +- packages/uniwind/src/core/web/index.ts | 1 + packages/uniwind/src/core/web/webUtils.ts | 1 + .../hooks/useCSSVariable/useCSSVariable.ts | 13 +- .../components/scoped-variables.test.tsx | 117 ++++----------- packages/uniwind/tests/type-test/theme.ts | 6 +- .../web/components/scoped-variables.test.tsx | 28 +--- 17 files changed, 82 insertions(+), 388 deletions(-) delete mode 100644 apps/expo-example/ScopedVariablesDemo.tsx create mode 100644 packages/uniwind/src/core/web/webUtils.ts diff --git a/CONTEXT.md b/CONTEXT.md index 7d67cb72..52a48891 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -67,8 +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, layout direction, and the opt-in `ScopedVariables` cache key. -- `ScopedVariables` subtrees bypass the cache by default (variables are overlaid onto a prototype-chained clone of the theme vars during resolve); an opt-in `cacheKey` is folded into the cache key to restore caching. Caller owns key stability. +- 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. @@ -92,7 +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` (and optional `variablesCacheKey` via its `cacheKey` prop); scoped subtree overrides CSS variables for style resolution and `useCSSVariable` without mutating the global theme. Nested providers merge `{ ...ancestor, ...own }`, nearest wins; keys must start with `--` (invalid keys log a dev error); values reuse `updateCSSVariables` normalization. All scoping primitives spread the full parent context so sibling fields compose without clobbering. +- `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 diff --git a/apps/expo-example/App.tsx b/apps/expo-example/App.tsx index 6df94220..b5539b3e 100644 --- a/apps/expo-example/App.tsx +++ b/apps/expo-example/App.tsx @@ -1,6 +1,5 @@ import './global.css' import { ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native' -import { ScopedVariablesDemo } from './ScopedVariablesDemo' const TailwindTestPage = () => { return ( @@ -655,8 +654,6 @@ const TailwindTestPage = () => { - - ) } diff --git a/apps/expo-example/ScopedVariablesDemo.tsx b/apps/expo-example/ScopedVariablesDemo.tsx deleted file mode 100644 index 77879aec..00000000 --- a/apps/expo-example/ScopedVariablesDemo.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { Text, View } from 'react-native' -import { ScopedVariables, useCSSVariable } from 'uniwind' - -// Where a variable's value comes from at the point it is read: -// - default: the theme value from global.css (no provider overrode it) -// - override: set by the nearest wrapping this card -// - inherited: set by an ancestor provider and cascaded down unchanged -type Origin = 'default' | 'override' | 'inherited' - -const ORIGIN_META: Record = { - default: { label: 'theme default', pill: 'bg-gray-200', text: 'text-gray-700' }, - override: { label: 'set here', pill: 'bg-green-200', text: 'text-green-900' }, - inherited: { label: 'inherited', pill: 'bg-blue-200', text: 'text-blue-900' }, -} - -const OriginPill = ({ origin }: { origin: Origin }) => { - const meta = ORIGIN_META[origin] - - return ( - - {meta.label} - - ) -} - -// One variable, its resolved value, a swatch (for colors), and an origin pill. -const VarRow = ({ name, origin }: { name: string; origin: Origin }) => { - const value = useCSSVariable(name) - const isColor = typeof value === 'string' && value.startsWith('#') - - return ( - - {isColor - ? - : ( - - # - - )} - - {name} = {value === undefined ? '(unset)' : String(value)} - - - - ) -} - -// A row of blocks separated by gap-(--gap). The space between the blocks IS -// the --gap value, so the difference between cards is directly visible. -const GapStrip = () => ( - - - - - - ← space between = --gap - -) - -// The card's header bar uses --color-primary (so you SEE the accent), and the -// GapStrip visualizes --gap as the space between blocks (so you SEE it change). -const AccentCard = ({ title, primary, gap }: { title: string; primary: Origin; gap: Origin }) => ( - - - {title} - - - - - - - -) - -const SectionHeader = ({ title, subtitle }: { title: string; subtitle: string }) => ( - - {title} - {subtitle} - -) - -export const ScopedVariablesDemo = () => { - return ( - - Scoped Variables - - Every card below is the same component. Only the wrapping {''}{' '} - changes the values it reads. - - - {/* Legend explaining the origin pills */} - - Value origin: - - - - - - {/* 1. No provider — values come straight from global.css */} - - - - {/* 2. One provider overriding both variables */} - - - - - - {/* 3. Nested — inner sits INSIDE outer, overrides color, inherits gap */} - - - - - ↳ nested inside “Outer card” - - - - - - - {/* 4. A scoped override plus an opt-in stable cacheKey (native caching) */} - - - - - - ) -} diff --git a/apps/expo-example/global.css b/apps/expo-example/global.css index ce789caf..6011adf8 100644 --- a/apps/expo-example/global.css +++ b/apps/expo-example/global.css @@ -1,17 +1,6 @@ @import 'tailwindcss'; @import 'uniwind'; -/* Theme defaults consumed by the ScopedVariables demo. Outside any - subtree these are the values that apply. */ -@theme { - --color-primary: #2b7fff; - --color-surface: #ffffff; -} - -:root { - --gap: 8px; -} - @layer theme { :root { @variant dark { diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx index a4bbe1bf..46846ffc 100644 --- a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx @@ -1,21 +1,18 @@ import React, { useMemo } from 'react' -import { View } from 'react-native' import { UniwindContext, useUniwindContext } from '../../core/context' import type { UniwindContextType } from '../../core/types' import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils' -export const ScopedVariables: React.FC> = ({ variables, cacheKey, children }) => { +export const ScopedVariables: React.FC> = ({ variables, children }) => { const uniwindContext = useUniwindContext() const value = useMemo( - () => buildScopedVariablesContext(uniwindContext, variables, cacheKey), - [uniwindContext, variables, cacheKey], + () => buildScopedVariablesContext(uniwindContext, variables), + [uniwindContext, variables], ) return ( - - - {children} - - + + {children} + ) } diff --git a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx index 1398f047..1aaa7944 100644 --- a/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx @@ -1,30 +1,24 @@ 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> = ({ variables, cacheKey, children }) => { +export const ScopedVariables: React.FC> = ({ variables, children }) => { const uniwindContext = useUniwindContext() const value = useMemo( - () => buildScopedVariablesContext(uniwindContext, variables, cacheKey), - [uniwindContext, variables, cacheKey], + () => buildScopedVariablesContext(uniwindContext, variables), + [uniwindContext, variables], ) - - // Apply the overrides as inline custom properties on the wrapper so the DOM - // cascade resolves `var(--name)` to the scoped value for every descendant. - // Custom properties inherit through `display: contents`, so children still - // pick these up even though the wrapper generates no box. Numbers are - // converted to px, matching `Uniwind.updateCSSVariables` on web. + // Inline custom properties so the DOM cascade resolves var(--name) for descendants const style = useMemo(() => { const result: Record = { display: 'contents' } - for (const [name, variableValue] of Object.entries(variables)) { - if (!name.startsWith('--')) { - continue + Object.entries(variables).forEach(([name, variableValue]) => { + if (name.startsWith('--')) { + result[name] = toWebValue(variableValue) } - - result[name] = typeof variableValue === 'number' ? `${variableValue}px` : variableValue - } + }) return result as React.CSSProperties }, [variables]) diff --git a/packages/uniwind/src/components/ScopedVariables/utils.ts b/packages/uniwind/src/components/ScopedVariables/utils.ts index 8201cad2..1962b7ea 100644 --- a/packages/uniwind/src/components/ScopedVariables/utils.ts +++ b/packages/uniwind/src/components/ScopedVariables/utils.ts @@ -2,39 +2,9 @@ import { Logger } from '../../core/logger' import type { CSSVariables, UniwindContextType } from '../../core/types' export type ScopedVariablesProps = { - /** - * CSS variables to override for this subtree. Keys must start with `--`; - * values are `string | number` (numbers become px on web, colors are - * normalized on native). - * - * Prefer a **stable** reference — define this object outside render or wrap - * it in `useMemo`. An inline object is a new reference every render, which - * recomputes the provider value and re-renders every consumer in the - * subtree (the same guidance as any React context provider value). - */ variables: CSSVariables - /** - * Opt-in native style caching for this subtree. - * - * By default a `` subtree bypasses the native style cache - * (styles are recomputed on every render) so overrides always take effect - * without serializing the variable map on every resolve. - * - * Pass a **stable** string that uniquely identifies this variable set and - * the native runtime will fold it into the style cache key and reuse cached - * results. You own the stability contract: if you pass the same `cacheKey` - * for different variable values, stale styles will be served. - * - * Caching only engages when every ancestor `` in the merge - * chain also opted in; if any ancestor bypasses, this subtree bypasses too. - */ - cacheKey?: string } -// Drop keys that aren't custom properties in every build, not just dev: an -// invalid key left in `context.variables` reaches `applyScopedVariables`, which -// would set an inheritable property (e.g. `color`) on the web read helper and -// could corrupt a resolved value. Only the dev warning is gated on `__DEV__`. const validateVariables = (variables: CSSVariables) => Object.fromEntries( Object.entries(variables).filter(([name]) => { @@ -50,35 +20,23 @@ const validateVariables = (variables: CSSVariables) => }), ) -/** - * Builds the context value for a `` provider. - * - * - Merges variables with ancestors: `{ ...inherited, ...own }`, nearest wins. - * - Derives `variablesCacheKey` for the native cache: - * - `null` (bypass) when no `cacheKey` is supplied, or when an ancestor with - * variables did not opt in (its variable set is not guaranteed stable). - * - otherwise a combined, collision-resistant key of ancestor + own keys. - */ export const buildScopedVariablesContext = ( parent: UniwindContextType, variables: CSSVariables, - cacheKey: string | undefined, ): UniwindContextType => { - const ancestorHasVariables = parent.variables !== null - const ancestorIsCacheable = !ancestorHasVariables || parent.variablesCacheKey !== null - - // NULL separator can't appear in user-supplied keys, so ancestor + own - // keys combine without ambiguity. - const variablesCacheKey = cacheKey === undefined || !ancestorIsCacheable - ? null - : `${parent.variablesCacheKey ?? ''}\0${cacheKey}` + // Merged with ancestors, nearest wins + const mergedVariables = { + ...parent.variables, + ...validateVariables(variables), + } + const variablesCacheKey = Object + .entries(mergedVariables) + .sort(([a], [b]) => a.localeCompare(b)) + .reduce((acc, [key, value]) => `${acc}${key}:${value};`, '') return { ...parent, - variables: { - ...parent.variables, - ...validateVariables(variables), - }, + variables: mergedVariables, variablesCacheKey, } } diff --git a/packages/uniwind/src/core/config/config.ts b/packages/uniwind/src/core/config/config.ts index c5d06173..d809a0b0 100644 --- a/packages/uniwind/src/core/config/config.ts +++ b/packages/uniwind/src/core/config/config.ts @@ -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 = { @@ -49,10 +49,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { uniwindRules.forEach(rule => { if (rule.theme === theme) { - rule.style.setProperty( - varName, - typeof varValue === 'number' ? `${varValue}px` : varValue, - ) + rule.style.setProperty(varName, toWebValue(varValue)) return } diff --git a/packages/uniwind/src/core/native/native-utils.ts b/packages/uniwind/src/core/native/native-utils.ts index 2d7a27bb..d8219d69 100644 --- a/packages/uniwind/src/core/native/native-utils.ts +++ b/packages/uniwind/src/core/native/native-utils.ts @@ -1,16 +1,7 @@ import { formatHex, formatHex8, interpolate, parse } from 'culori' import type { UniwindRuntime, Var } from '../types' -/** - * Normalizes a CSS variable value for the native runtime and wraps it in a - * lazy getter, matching the behavior of `Uniwind.updateCSSVariables`: - * - numbers are passed through as-is - * - color strings are parsed with culori and formatted as hex / hex8 - * - anything else is returned verbatim - * - * Getters keep lazy semantics so a value can depend on the current vars bag - * (e.g. custom property references) when resolved. - */ +// 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 diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index a089f69c..34ffe542 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -31,13 +31,6 @@ class UniwindStoreBuilder { } const isScopedTheme = uniwindContext.scopedTheme !== null - // Subtrees with scoped variables () bypass the cache by - // default: keying the cache on the variable map would require - // serializing it on every resolve (expensive) and would bloat the cache - // with per-subtree entries, so we recompute instead. When the caller - // opts in via a stable `cacheKey`, `variablesCacheKey` is set and folded - // into the cache key below so the normal cache path is used. - const bypassCache = uniwindContext.variables !== null && uniwindContext.variablesCacheKey === null const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${ uniwindContext.rtl ?? '' }${uniwindContext.variablesCacheKey ?? ''}` @@ -47,14 +40,14 @@ class UniwindStoreBuilder { return emptyState } - if (!bypassCache && cache.has(cacheKey)) { + if (cache.has(cacheKey)) { return cache.get(cacheKey)! } const result = this.resolveStyles(className, componentProps, state, uniwindContext) - // Don't cache styles that depend on data attributes or that bypass the cache - if (!bypassCache && !result.hasDataAttributes) { + // Don't cache styles that depend on data attributes + if (!result.hasDataAttributes) { cache.set(cacheKey, result) UniwindListener.subscribe( () => cache.delete(cacheKey), @@ -110,10 +103,7 @@ class UniwindStoreBuilder { const theme = uniwindContext.scopedTheme ?? this.runtime.currentThemeName // At this point we're sure that theme is correct const themeVars = this.vars[theme]! - // Overlay scoped variables (from ) onto the theme vars. - // We clone via the prototype chain so theme defaults still resolve for - // any variable the subtree didn't override, and so getters that read - // other variables (custom property references) see the overrides too. + // Overlay scoped variables onto a prototype-chained clone so unset vars fall through to the theme let vars = uniwindContext.variables === null ? themeVars : Object.assign( diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index e67cca86..234b31d9 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -2,6 +2,7 @@ import { generateDataSet } from '../../components/web/generateDataSet' import type { RNStyle, UniwindContextType } from '../types' import { CSSListener } from './cssListener' import { parseCSSValue } from './parseCSSValue' +import { toWebValue } from './webUtils' const dummyParent = typeof document !== 'undefined' ? Object.assign(document.createElement('div'), { @@ -17,12 +18,8 @@ if (dummyParent && dummy) { dummyParent.appendChild(dummy) } -/** - * Applies scoped CSS variables (from ) to `dummyParent` as - * inline custom properties so they cascade to `dummy` during style computation. - * Numbers are converted to px, matching `Uniwind.updateCSSVariables` on web. - * Returns a disposer that removes the applied properties again. - */ +// Applies scoped variables to dummyParent so they cascade to dummy during style +// computation. Returns a disposer that removes them again const applyScopedVariables = (uniwindContext: UniwindContextType) => { if (!dummyParent || uniwindContext.variables === null) { return () => {} @@ -31,7 +28,7 @@ const applyScopedVariables = (uniwindContext: UniwindContextType) => { const names = Object.keys(uniwindContext.variables) Object.entries(uniwindContext.variables).forEach(([name, value]) => { - dummyParent.style.setProperty(name, typeof value === 'number' ? `${value}px` : value) + dummyParent.style.setProperty(name, toWebValue(value)) }) return () => { @@ -102,8 +99,6 @@ export const getWebStyles = ( const disposeScopedVariables = applyScopedVariables(uniwindContext) - // finally: the scoped custom properties must be cleared even if a DOM read - // throws, otherwise `dummyParent` keeps them and the next resolve is stale. try { dummy.className = className diff --git a/packages/uniwind/src/core/web/index.ts b/packages/uniwind/src/core/web/index.ts index 4ddd6e36..5163195b 100644 --- a/packages/uniwind/src/core/web/index.ts +++ b/packages/uniwind/src/core/web/index.ts @@ -2,3 +2,4 @@ export * from './cssListener' export * from './formatColor' export * from './getWebStyles' export * from './parseCSSValue' +export * from './webUtils' diff --git a/packages/uniwind/src/core/web/webUtils.ts b/packages/uniwind/src/core/web/webUtils.ts new file mode 100644 index 00000000..d25f6a8c --- /dev/null +++ b/packages/uniwind/src/core/web/webUtils.ts @@ -0,0 +1 @@ +export const toWebValue = (value: string | number) => typeof value === 'number' ? `${value}px` : value diff --git a/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts index 2f890cf0..8af79026 100644 --- a/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts +++ b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts @@ -61,6 +61,7 @@ export const useCSSVariable: GetCSSVariable = (name: string | Array) => const uniwindContext = useUniwindContext() const [value, setValue] = useState(getCSSVariable(name, uniwindContext)) const nameRef = useRef(name) + const isMountRef = useRef(true) useLayoutEffect(() => { if (Array.isArray(name) && Array.isArray(nameRef.current)) { @@ -82,10 +83,14 @@ export const useCSSVariable: GetCSSVariable = (name: string | Array) => useLayoutEffect(() => { const updateValue = () => setValue(getCSSVariable(nameRef.current, uniwindContext)) - // Recompute on context change too: a new `uniwindContext` (e.g. an - // updated `` prop or a nearer provider) can resolve to - // a different value without any global Theme/Variables event firing. - updateValue() + + // Skip mount as useState already resolved the value, recompute only when the context changes + if (isMountRef.current) { + isMountRef.current = false + } else { + updateValue() + } + const dispose = UniwindListener.subscribe( updateValue, [StyleDependency.Theme, StyleDependency.Variables], diff --git a/packages/uniwind/tests/native/components/scoped-variables.test.tsx b/packages/uniwind/tests/native/components/scoped-variables.test.tsx index 39bb545c..7acd7924 100644 --- a/packages/uniwind/tests/native/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -10,11 +10,7 @@ import type { UniwindContextType } from '../../../src/core/types' import { useCSSVariable } from '../../../src/hooks/useCSSVariable' import { renderUniwind } from '../utils' -// Utility classes below reference custom properties so the Tailwind scanner -// generates them into the native stylesheet: -// text-(--color-primary) -> color: var(--color-primary) -// gap-(--gap) -> gap: var(--gap) -// bg-background -> background-color: var(--color-background) +// Utility classes below reference custom properties so the Tailwind scanner generates them into the native stylesheet describe('ScopedVariables', () => { afterEach(() => { @@ -206,17 +202,14 @@ describe('ScopedVariables', () => { } }) - describe('cacheKey opt-in', () => { - // A cached resolve returns the SAME result object reference on a hit, - // while a bypass resolve returns a fresh object every time. We use that - // referential identity to observe caching directly. + describe('style caching', () => { const baseContext = { scopedTheme: null, rtl: null } as const - test('opting in caches: identical resolves return the same result reference', () => { + test('identical variables resolve from the cache', () => { const context: UniwindContextType = { ...baseContext, variables: { '--gap': 8 }, - variablesCacheKey: 'stable-key', + variablesCacheKey: '--gap:8;', } const first = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) @@ -226,116 +219,66 @@ describe('ScopedVariables', () => { expect(second).toBe(first) }) - test('different cacheKeys do not collide even with identical variables', () => { + test('different variable values do not collide', () => { const contextA: UniwindContextType = { ...baseContext, variables: { '--gap': 8 }, - variablesCacheKey: 'key-a', + variablesCacheKey: '--gap:8;', } const contextB: UniwindContextType = { ...baseContext, - variables: { '--gap': 8 }, - variablesCacheKey: 'key-b', + variables: { '--gap': 4 }, + variablesCacheKey: '--gap:4;', } const a = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextA) const b = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextB) - // Distinct cache entries -> distinct references, both correct expect(a).not.toBe(b) expect(a.styles.gap).toEqual(8) - expect(b.styles.gap).toEqual(8) - }) - - test('default (no cacheKey) still bypasses: resolves are fresh each time', () => { - const context: UniwindContextType = { - ...baseContext, - variables: { '--gap': 8 }, - variablesCacheKey: null, - } - - const first = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) - const second = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) - - // Bypass -> recomputed each call (no shared reference), still correct - expect(second).not.toBe(first) - expect(first.styles.gap).toEqual(8) - expect(second.styles.gap).toEqual(8) - }) - - test('component-level: cached subtree still resolves correctly across re-renders', () => { - const Wrapper = ({ color }: { color: string }) => ( - - - - ) - - const { getStylesFromId, rerender } = renderUniwind() - - expect(getStylesFromId('cached').color).toEqual('#3b82f6') - - // Re-render with the same cacheKey and same value -> still correct - act(() => { - rerender() - }) - - expect(getStylesFromId('cached').color).toEqual('#3b82f6') + expect(b.styles.gap).toEqual(4) }) - test('nested provider without cacheKey under a cached parent falls back to bypass', () => { + test('derived cache key reflects the merged variables', () => { const Probe = (props: { test: jest.Mock }) => { - const { variablesCacheKey } = useUniwindContext() - - props.test(variablesCacheKey) + props.test(useUniwindContext().variablesCacheKey) return null } const outer = jest.fn() - const innerCached = jest.fn() - const innerBypass = jest.fn() + const inner = jest.fn() renderUniwind( - + - - - - - + + , ) - // Outer opted in (leading delimiter is an internal detail) - expect(outer).toHaveBeenCalledWith(expect.stringContaining('outer')) - // Nested opt-in composes the ancestor key so it can't collide - expect(innerCached).toHaveBeenCalledWith(expect.stringContaining('outer')) - expect(innerCached).toHaveBeenCalledWith(expect.stringContaining('inner')) - // Nested WITHOUT a key bypasses (null) even under a cached parent - expect(innerBypass).toHaveBeenCalledWith(null) + expect(outer).toHaveBeenCalledWith('--gap:8;') + // Inner key includes the inherited variables, so nested subtrees can't collide + expect(inner).toHaveBeenCalledWith('--color-primary:#ff0000;--gap:8;') }) - test('opt-in provider under a bypassing ancestor still bypasses', () => { - const Probe = (props: { test: jest.Mock }) => { - props.test(useUniwindContext().variablesCacheKey) + test('updating the variables prop does not serve stale cached styles', () => { + const Wrapper = ({ color }: { color: string }) => ( + + + + ) - return null - } + const { getStylesFromId, rerender } = renderUniwind() - const inner = jest.fn() + expect(getStylesFromId('cached').color).toEqual('#3b82f6') - renderUniwind( - - - - - , - ) + act(() => { + rerender() + }) - // Ancestor did not opt in -> the merged variable set is not stable, - // so this subtree bypasses regardless of its own cacheKey. - expect(inner).toHaveBeenCalledWith(null) + expect(getStylesFromId('cached').color).toEqual('#ff0000') }) }) }) diff --git a/packages/uniwind/tests/type-test/theme.ts b/packages/uniwind/tests/type-test/theme.ts index da8df6f5..ef2eb6e1 100644 --- a/packages/uniwind/tests/type-test/theme.ts +++ b/packages/uniwind/tests/type-test/theme.ts @@ -1,5 +1,5 @@ import type { ComponentProps } from 'react' -import { ScopedTheme, ScopedVariables, type ThemeName, Uniwind, useUniwind } from 'uniwind' +import { ScopedTheme, type ThemeName, Uniwind, useUniwind } from 'uniwind' import { type Equal, type Expect } from './checks' type ExpectedThemeName = 'light' | 'dark' | 'premium' | 'custom' @@ -28,7 +28,3 @@ type UniwindUpdateCSSVariablesThemeTest = Expect['theme'] type ScopedThemeThemePropTest = Expect> - -// ScopedVariables variables prop -type ScopedVariablesProp = ComponentProps['variables'] -type ScopedVariablesPropTest = Expect>> diff --git a/packages/uniwind/tests/web/components/scoped-variables.test.tsx b/packages/uniwind/tests/web/components/scoped-variables.test.tsx index 05f1fda5..00be8fd3 100644 --- a/packages/uniwind/tests/web/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/web/components/scoped-variables.test.tsx @@ -93,9 +93,6 @@ describe('ScopedVariables (web)', () => { }) test('applies the variables as inline custom properties on the wrapper', () => { - // The real DOM cascade (RNW passes classes through as-is) resolves - // `var(--name)` against these inline properties, so they must land on - // the wrapper element itself, not only on the hidden read helper. const { getByText } = render( scoped content @@ -110,8 +107,6 @@ describe('ScopedVariables (web)', () => { }) test('nested wrappers each carry only their own overrides inline', () => { - // Inheritance comes from the DOM cascade, so the inner wrapper only - // needs to declare what it overrides; --gap keeps cascading from outer. const { getByText } = render( @@ -145,30 +140,9 @@ describe('ScopedVariables (web)', () => { getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: { '--gap': 8 }, variablesCacheKey: null }), ).toEqual('8px') - // After resolving with scoped variables, the dummy parent no longer - // carries the property (it is cleared), so a plain read falls back. + // The dummy parent is cleared after the read, so a plain read falls back expect( getWebVariable('--gap', { scopedTheme: null, rtl: null, variables: null, variablesCacheKey: null }), ).toEqual('') }) - - test('cacheKey prop is accepted on web and does not change the resolved value', () => { - // The web read path has no memo cache, so cacheKey is a no-op there; - // it must still resolve scoped variables normally. - const inside = jest.fn() - - const Probe = ({ test }: { test: jest.Mock }) => { - test(useCSSVariable('--color-primary')) - - return null - } - - render( - - - , - ) - - expect(inside).toHaveBeenCalledWith('#3b82f6') - }) }) From 367cadcd05038e69e51cb58be865993e934053cc Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Thu, 23 Jul 2026 10:11:05 -0700 Subject: [PATCH 7/7] fix(ScopedVariables): use JSON.stringify for the derived cache key The key:value; concatenation had no escaping, so values containing separators could collide ({'--a': '1;--b:2'} vs {'--a': '1', '--b': '2'}) and serve wrong cached styles. --- .../src/components/ScopedVariables/utils.ts | 7 ++-- .../components/scoped-variables.test.tsx | 34 ++++++++++++++++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/uniwind/src/components/ScopedVariables/utils.ts b/packages/uniwind/src/components/ScopedVariables/utils.ts index 1962b7ea..e065a3b3 100644 --- a/packages/uniwind/src/components/ScopedVariables/utils.ts +++ b/packages/uniwind/src/components/ScopedVariables/utils.ts @@ -29,10 +29,9 @@ export const buildScopedVariablesContext = ( ...parent.variables, ...validateVariables(variables), } - const variablesCacheKey = Object - .entries(mergedVariables) - .sort(([a], [b]) => a.localeCompare(b)) - .reduce((acc, [key, value]) => `${acc}${key}:${value};`, '') + const variablesCacheKey = JSON.stringify( + Object.entries(mergedVariables).sort(([a], [b]) => a.localeCompare(b)), + ) return { ...parent, diff --git a/packages/uniwind/tests/native/components/scoped-variables.test.tsx b/packages/uniwind/tests/native/components/scoped-variables.test.tsx index 7acd7924..2731e680 100644 --- a/packages/uniwind/tests/native/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -209,7 +209,7 @@ describe('ScopedVariables', () => { const context: UniwindContextType = { ...baseContext, variables: { '--gap': 8 }, - variablesCacheKey: '--gap:8;', + variablesCacheKey: '[["--gap",8]]', } const first = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, context) @@ -223,12 +223,12 @@ describe('ScopedVariables', () => { const contextA: UniwindContextType = { ...baseContext, variables: { '--gap': 8 }, - variablesCacheKey: '--gap:8;', + variablesCacheKey: '[["--gap",8]]', } const contextB: UniwindContextType = { ...baseContext, variables: { '--gap': 4 }, - variablesCacheKey: '--gap:4;', + variablesCacheKey: '[["--gap",4]]', } const a = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextA) @@ -258,9 +258,33 @@ describe('ScopedVariables', () => { , ) - expect(outer).toHaveBeenCalledWith('--gap:8;') + expect(outer).toHaveBeenCalledWith('[["--gap",8]]') // Inner key includes the inherited variables, so nested subtrees can't collide - expect(inner).toHaveBeenCalledWith('--color-primary:#ff0000;--gap:8;') + expect(inner).toHaveBeenCalledWith('[["--color-primary","#ff0000"],["--gap",8]]') + }) + + test('values containing separators do not produce colliding keys', () => { + const Probe = (props: { test: jest.Mock }) => { + props.test(useUniwindContext().variablesCacheKey) + + return null + } + + const ambiguous = jest.fn() + const plain = jest.fn() + + renderUniwind( + + + + + + + + , + ) + + expect(ambiguous.mock.calls[0][0]).not.toEqual(plain.mock.calls[0][0]) }) test('updating the variables prop does not serve stale cached styles', () => {