diff --git a/CONTEXT.md b/CONTEXT.md index bdcfe76b..52a48891 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. @@ -65,7 +67,8 @@ Native runtime: - Build output injects a generated stylesheet callback into `Uniwind.__reinit(...)`. - `UniwindStore` holds generated style records, theme variables, scoped variables, runtime state, and per-theme caches. - `UniwindStore.getStyles(className, props, state, context)` resolves classes into React Native style objects. -- Cache keys include class names, component state, whether theme is scoped, and explicit layout direction context. +- Cache keys include class names, component state, whether theme is scoped, layout direction, and a key derived from the merged `ScopedVariables` map. +- During resolve, `ScopedVariables` overrides are overlaid onto a prototype-chained clone of the theme vars so unset variables fall through to the theme. - Resolved styles subscribe to only dependencies they use, then invalidate cache entries on change. - Runtime dependencies are represented by `StyleDependency`: theme, dimensions, orientation, insets, font scale, RTL, adaptive themes, and variables. - Native style resolution filters rules by screen width, orientation, theme, RTL, active/focus/disabled state, and `data-*` props. @@ -78,6 +81,7 @@ Web runtime: - `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. - `ScopedTheme` renders a `div` with the theme class and `display: contents` on web. - `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree. +- `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the hidden `dummyParent`, then clears them. - Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element. Shared runtime: @@ -88,6 +92,7 @@ Shared runtime: - `Uniwind.updateInsets(insets)` is native-only behavior and updates safe-area-style runtime values. - `ScopedTheme` sets `UniwindContext.scopedTheme`; scoped subtree ignores global theme changes for style resolution. - `LayoutDirection` sets `UniwindContext.rtl`; scoped subtree uses that direction for RTL/LTR variant resolution instead of global runtime RTL. +- `ScopedVariables` sets `UniwindContext.variables`; the subtree overrides CSS variables for style resolution and `useCSSVariable` without mutating the global theme. Nested providers merge with ancestors, nearest wins. ## Build And Bundler Model 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..46846ffc --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.native.tsx @@ -0,0 +1,18 @@ +import React, { useMemo } from 'react' +import { UniwindContext, useUniwindContext } from '../../core/context' +import type { UniwindContextType } from '../../core/types' +import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils' + +export const ScopedVariables: React.FC> = ({ variables, children }) => { + const uniwindContext = useUniwindContext() + const value = useMemo( + () => buildScopedVariablesContext(uniwindContext, 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..1aaa7944 --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/ScopedVariables.tsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react' +import { UniwindContext, useUniwindContext } from '../../core/context' +import type { UniwindContextType } from '../../core/types' +import { toWebValue } from '../../core/web/webUtils' +import { buildScopedVariablesContext, type ScopedVariablesProps } from './utils' + +export const ScopedVariables: React.FC> = ({ variables, children }) => { + const uniwindContext = useUniwindContext() + const value = useMemo( + () => buildScopedVariablesContext(uniwindContext, variables), + [uniwindContext, variables], + ) + // Inline custom properties so the DOM cascade resolves var(--name) for descendants + const style = useMemo(() => { + const result: Record = { display: 'contents' } + + Object.entries(variables).forEach(([name, variableValue]) => { + if (name.startsWith('--')) { + result[name] = toWebValue(variableValue) + } + }) + + return result as React.CSSProperties + }, [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/components/ScopedVariables/utils.ts b/packages/uniwind/src/components/ScopedVariables/utils.ts new file mode 100644 index 00000000..e065a3b3 --- /dev/null +++ b/packages/uniwind/src/components/ScopedVariables/utils.ts @@ -0,0 +1,41 @@ +import { Logger } from '../../core/logger' +import type { CSSVariables, UniwindContextType } from '../../core/types' + +export type ScopedVariablesProps = { + variables: CSSVariables +} + +const validateVariables = (variables: CSSVariables) => + Object.fromEntries( + Object.entries(variables).filter(([name]) => { + if (!name.startsWith('--')) { + if (__DEV__) { + Logger.error(`CSS variable name must start with "--", instead got: ${name}`) + } + + return false + } + + return true + }), + ) + +export const buildScopedVariablesContext = ( + parent: UniwindContextType, + variables: CSSVariables, +): UniwindContextType => { + // Merged with ancestors, nearest wins + const mergedVariables = { + ...parent.variables, + ...validateVariables(variables), + } + const variablesCacheKey = JSON.stringify( + Object.entries(mergedVariables).sort(([a], [b]) => a.localeCompare(b)), + ) + + return { + ...parent, + variables: mergedVariables, + variablesCacheKey, + } +} diff --git a/packages/uniwind/src/core/config/config.common.ts b/packages/uniwind/src/core/config/config.common.ts index 674d903d..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 }) + 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.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..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 = { @@ -42,15 +42,14 @@ 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, variablesCacheKey: null })], + ), ) uniwindRules.forEach(rule => { if (rule.theme === theme) { - rule.style.setProperty( - varName, - typeof varValue === 'number' ? `${varValue}px` : varValue, - ) + rule.style.setProperty(varName, toWebValue(varValue)) return } diff --git a/packages/uniwind/src/core/context.ts b/packages/uniwind/src/core/context.ts index a62349d5..65154b9c 100644 --- a/packages/uniwind/src/core/context.ts +++ b/packages/uniwind/src/core/context.ts @@ -1,9 +1,11 @@ import { createContext, use } from 'react' -import type { ThemeName } from './types' +import type { CSSVariables, ThemeName } from './types' export const UniwindContext = createContext({ scopedTheme: null as ThemeName | null, rtl: null as boolean | null, + variables: null as CSSVariables | null, + variablesCacheKey: null as string | null, }) export const useUniwindContext = () => use(UniwindContext) diff --git a/packages/uniwind/src/core/native/native-utils.ts b/packages/uniwind/src/core/native/native-utils.ts index a29ba8d8..d8219d69 100644 --- a/packages/uniwind/src/core/native/native-utils.ts +++ b/packages/uniwind/src/core/native/native-utils.ts @@ -1,5 +1,22 @@ import { formatHex, formatHex8, interpolate, parse } from 'culori' -import type { UniwindRuntime } from '../types' +import type { UniwindRuntime, Var } from '../types' + +// Normalizes a CSS variable value (numbers pass through, colors -> hex) and wraps it in a lazy getter +export const createVarGetter = (value: string | number): Var => () => { + if (typeof value === 'number') { + return value + } + + const parsedColor = parse(value) + + if (parsedColor) { + return parsedColor.alpha === undefined || parsedColor.alpha === 1 + ? formatHex(parsedColor) + : formatHex8(parsedColor) + } + + return value +} export const colorMix = (color: string, weight: number | string, mixColor: string) => { const parsedWeight = typeof weight === 'string' diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index e2c676aa..34ffe542 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' @@ -32,7 +33,7 @@ class UniwindStoreBuilder { const isScopedTheme = uniwindContext.scopedTheme !== null const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${ uniwindContext.rtl ?? '' - }` + }${uniwindContext.variablesCacheKey ?? ''}` const cache = this.cache[uniwindContext.scopedTheme ?? this.runtime.currentThemeName] if (!cache) { @@ -101,7 +102,16 @@ 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 onto a prototype-chained clone so unset vars fall through to the theme + let vars = uniwindContext.variables === null + ? themeVars + : Object.assign( + Object.create(themeVars) as Vars, + Object.fromEntries( + Object.entries(uniwindContext.variables).map(([name, value]) => [name, createVarGetter(value)]), + ), + ) const originalVars = vars let hasDataAttributes = false const dependencies = new Set() diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index c4510bf1..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,6 +18,24 @@ if (dummyParent && dummy) { dummyParent.appendChild(dummy) } +// 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 () => {} + } + + const names = Object.keys(uniwindContext.variables) + + Object.entries(uniwindContext.variables).forEach(([name, value]) => { + dummyParent.style.setProperty(name, toWebValue(value)) + }) + + return () => { + names.forEach(name => dummyParent.style.removeProperty(name)) + } +} + const getActiveStylesForClass = (className: string) => { const extractedStyles = {} as Record @@ -78,37 +97,43 @@ export const getWebStyles = ( dummyParent?.removeAttribute('dir') } - dummy.className = className - - const dataSet = generateDataSet(componentProps ?? {}) + const disposeScopedVariables = applyScopedVariables(uniwindContext) - Object.entries(dataSet).forEach(([key, value]) => { - if (value === false || value === undefined) { - return - } + try { + dummy.className = className - dummy.dataset[key] = String(value) - }) + const dataSet = generateDataSet(componentProps ?? {}) - 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 + } - 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) => { @@ -128,7 +153,13 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType) dummyParent.removeAttribute('dir') } - const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) + const disposeScopedVariables = applyScopedVariables(uniwindContext) - return parseCSSValue(variable) + try { + const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) + + return parseCSSValue(variable) + } finally { + disposeScopedVariables() + } } 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/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/hooks/useCSSVariable/useCSSVariable.ts b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts index dcae35b5..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,6 +83,14 @@ export const useCSSVariable: GetCSSVariable = (name: string | Array) => useLayoutEffect(() => { const updateValue = () => setValue(getCSSVariable(nameRef.current, uniwindContext)) + + // 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/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..7243f911 100644 --- a/packages/uniwind/tests/consts.ts +++ b/packages/uniwind/tests/consts.ts @@ -17,4 +17,6 @@ export const SCREEN_HEIGHT = 844 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 88165a22..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 }, + 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 }) + 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 }) + 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 }) + 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 }) + 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 new file mode 100644 index 00000000..2731e680 --- /dev/null +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -0,0 +1,308 @@ +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 { 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' + +// Utility classes below reference custom properties so the Tailwind scanner generates them into the native stylesheet + +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('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( + + + + + + + + + + + + , + ) + + 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( + + + , + ) + + 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() + } + }) + + describe('style caching', () => { + const baseContext = { scopedTheme: null, rtl: null } as const + + test('identical variables resolve from the cache', () => { + const context: UniwindContextType = { + ...baseContext, + variables: { '--gap': 8 }, + variablesCacheKey: '[["--gap",8]]', + } + + 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 variable values do not collide', () => { + const contextA: UniwindContextType = { + ...baseContext, + variables: { '--gap': 8 }, + variablesCacheKey: '[["--gap",8]]', + } + const contextB: UniwindContextType = { + ...baseContext, + variables: { '--gap': 4 }, + variablesCacheKey: '[["--gap",4]]', + } + + const a = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextA) + const b = UniwindStore.getStyles('gap-(--gap)', undefined, undefined, contextB) + + expect(a).not.toBe(b) + expect(a.styles.gap).toEqual(8) + expect(b.styles.gap).toEqual(4) + }) + + test('derived cache key reflects the merged variables', () => { + const Probe = (props: { test: jest.Mock }) => { + props.test(useUniwindContext().variablesCacheKey) + + return null + } + + const outer = jest.fn() + const inner = jest.fn() + + renderUniwind( + + + + + + , + ) + + 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('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', () => { + const Wrapper = ({ color }: { color: string }) => ( + + + + ) + + const { getStylesFromId, rerender } = renderUniwind() + + expect(getStylesFromId('cached').color).toEqual('#3b82f6') + + act(() => { + rerender() + }) + + expect(getStylesFromId('cached').color).toEqual('#ff0000') + }) + }) +}) 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..00be8fd3 --- /dev/null +++ b/packages/uniwind/tests/web/components/scoped-variables.test.tsx @@ -0,0 +1,148 @@ +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('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> = [] + + 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('applies the variables as inline custom properties on the wrapper', () => { + 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', () => { + 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( + + 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 }), + ).toEqual('8px') + + // 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('') + }) +})