diff --git a/packages/uniwind/src/components/native/useStyle.ts b/packages/uniwind/src/components/native/useStyle.ts index cfea356d..c7e185bb 100644 --- a/packages/uniwind/src/components/native/useStyle.ts +++ b/packages/uniwind/src/components/native/useStyle.ts @@ -12,7 +12,9 @@ export const useStyle = (className: string | undefined, componentProps: Record { if (__DEV__ || styleState.dependencies.length > 0) { - const dispose = UniwindListener.subscribe(rerender, styleState.dependencies) + const dispose = UniwindListener.subscribe(rerender, styleState.dependencies, { + shouldNotifyVariables: (theme) => theme === (uniwindContext.scopedTheme ?? UniwindStore.runtime.currentThemeName), + }) return dispose } diff --git a/packages/uniwind/src/core/config/config.native.ts b/packages/uniwind/src/core/config/config.native.ts index 3c51bc19..d96be510 100644 --- a/packages/uniwind/src/core/config/config.native.ts +++ b/packages/uniwind/src/core/config/config.native.ts @@ -44,9 +44,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { }) }) - if (theme === this.currentTheme) { - UniwindListener.notify([StyleDependency.Variables]) - } + UniwindListener.notifyVariables(theme) } updateInsets(insets: Insets) { diff --git a/packages/uniwind/src/core/config/config.ts b/packages/uniwind/src/core/config/config.ts index b659fa59..93ec368b 100644 --- a/packages/uniwind/src/core/config/config.ts +++ b/packages/uniwind/src/core/config/config.ts @@ -1,4 +1,3 @@ -import { StyleDependency } from '../../types' import { UniwindListener } from '../listener' import { Logger } from '../logger' import { CSSVariables, ThemeName } from '../types' @@ -11,6 +10,16 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { super() } + getRuntimeCSSVariableValue(theme: ThemeName, varName: string): string | number | undefined { + const vars = this.runtimeCSSVariables.get(theme) + + if (!vars || !Object.prototype.hasOwnProperty.call(vars, varName)) { + return undefined + } + + return vars[varName] + } + updateCSSVariables(theme: ThemeName, variables: CSSVariables) { Object.entries(variables).forEach(([varName, varValue]) => { if (!varName.startsWith('--') && __DEV__) { @@ -29,9 +38,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { } }) - if (theme === this.currentTheme) { - UniwindListener.notify([StyleDependency.Variables]) - } + UniwindListener.notifyVariables(theme) } protected onThemeChange() { diff --git a/packages/uniwind/src/core/listener.ts b/packages/uniwind/src/core/listener.ts index ef0083a2..f7a0881a 100644 --- a/packages/uniwind/src/core/listener.ts +++ b/packages/uniwind/src/core/listener.ts @@ -1,10 +1,13 @@ import { StyleDependency } from '../types' +import { ThemeName } from './types' type SubscribeOptions = { once?: boolean + shouldNotifyVariables?: (theme: ThemeName) => boolean } class UniwindListenerBuilder { + private notifyingVariablesTheme: ThemeName | null = null private listeners = { [StyleDependency.ColorScheme]: new Set<() => void>(), [StyleDependency.Theme]: new Set<() => void>(), @@ -23,6 +26,12 @@ class UniwindListenerBuilder { }) } + notifyVariables(theme: ThemeName) { + this.notifyingVariablesTheme = theme + this.listeners[StyleDependency.Variables].forEach(callback => callback()) + this.notifyingVariablesTheme = null + } + notifyAll() { Object.values(this.listeners).forEach(listenerSet => { listenerSet.forEach(callback => callback()) @@ -41,6 +50,14 @@ class UniwindListenerBuilder { dependencies.forEach(dep => { const wrappedListener = () => { + if (dep === StyleDependency.Variables && this.notifyingVariablesTheme !== null) { + const shouldNotify = options?.shouldNotifyVariables?.(this.notifyingVariablesTheme) ?? true + + if (!shouldNotify) { + return + } + } + listener() if (options?.once) { diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index 920682c8..0cca82ce 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -33,8 +33,9 @@ class UniwindStoreBuilder { } const isScopedTheme = uniwindContext.scopedTheme !== null + const theme = uniwindContext.scopedTheme ?? this.runtime.currentThemeName const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}` - const cache = this.cache[uniwindContext.scopedTheme ?? this.runtime.currentThemeName] + const cache = this.cache[theme] if (!cache) { return emptyState @@ -52,7 +53,10 @@ class UniwindStoreBuilder { UniwindListener.subscribe( () => cache.delete(cacheKey), result.dependencies, - { once: true }, + { + once: true, + shouldNotifyVariables: (changedTheme) => changedTheme === theme, + }, ) } diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index 92c081d5..069f74a4 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -1,4 +1,5 @@ import { generateDataSet } from '../../components/web/generateDataSet' +import { Uniwind } from '../config/config' import { RNStyle, UniwindContextType } from '../types' import { CSSListener } from './cssListener' import { parseCSSValue } from './parseCSSValue' @@ -110,6 +111,17 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType) return undefined } + const theme = uniwindContext.scopedTheme ?? Uniwind.currentTheme + const runtimeValue = Uniwind.getRuntimeCSSVariableValue(theme, name) + + if (runtimeValue !== undefined) { + return parseCSSValue( + typeof runtimeValue === 'number' + ? `${runtimeValue}px` + : runtimeValue, + ) + } + if (uniwindContext.scopedTheme !== null) { dummyParent.setAttribute('class', uniwindContext.scopedTheme) } else { diff --git a/packages/uniwind/src/hoc/withUniwind.native.tsx b/packages/uniwind/src/hoc/withUniwind.native.tsx index 768d776c..1260d6c4 100644 --- a/packages/uniwind/src/hoc/withUniwind.native.tsx +++ b/packages/uniwind/src/hoc/withUniwind.native.tsx @@ -22,6 +22,7 @@ export const withUniwind: WithUniwind = < const withAutoUniwind = (Component: Component) => (props: AnyObject) => { const uniwindContext = useUniwindContext() + const effectiveTheme = uniwindContext.scopedTheme ?? UniwindStore.runtime.currentThemeName const { dependencies, generatedProps } = Object.entries(props).reduce((acc, [propName, propValue]) => { if (isColorClassProperty(propName)) { const colorProp = classToColor(propName) @@ -73,10 +74,12 @@ const withAutoUniwind = (Component: Component) => (props: AnyObject) const [, rerender] = useReducer(() => ({}), {}) useLayoutEffect(() => { - const dispose = UniwindListener.subscribe(rerender, Array.from(new Set(dependencies))) + const dispose = UniwindListener.subscribe(rerender, Array.from(new Set(dependencies)), { + shouldNotifyVariables: (theme) => theme === effectiveTheme, + }) return dispose - }, [dependencySum]) + }, [dependencySum, effectiveTheme]) return ( ) => (props: AnyObject) const withManualUniwind = (Component: Component, options: Record) => (props: AnyObject) => { const uniwindContext = useUniwindContext() + const effectiveTheme = uniwindContext.scopedTheme ?? UniwindStore.runtime.currentThemeName const { generatedProps, dependencies } = Object.entries(options).reduce((acc, [propName, option]) => { const className = props[option.fromClassName] @@ -139,10 +143,12 @@ const withManualUniwind = (Component: Component, options: Record ({}), {}) useLayoutEffect(() => { - const dispose = UniwindListener.subscribe(rerender, Array.from(new Set(dependencies))) + const dispose = UniwindListener.subscribe(rerender, Array.from(new Set(dependencies)), { + shouldNotifyVariables: (theme) => theme === effectiveTheme, + }) return dispose - }, [dependencySum]) + }, [dependencySum, effectiveTheme]) return ( ) => const dispose = UniwindListener.subscribe( updateValue, [StyleDependency.Theme, StyleDependency.Variables], + { + shouldNotifyVariables: (theme) => theme === (uniwindContext.scopedTheme ?? Uniwind.currentTheme), + }, ) return dispose diff --git a/packages/uniwind/src/hooks/useResolveClassNames.native.ts b/packages/uniwind/src/hooks/useResolveClassNames.native.ts index ad06891f..38be33c1 100644 --- a/packages/uniwind/src/hooks/useResolveClassNames.native.ts +++ b/packages/uniwind/src/hooks/useResolveClassNames.native.ts @@ -5,6 +5,7 @@ import { UniwindStore } from '../core/native' export const useResolveClassNames = (className: string) => { const uniwindContext = useUniwindContext() + const effectiveTheme = uniwindContext.scopedTheme ?? UniwindStore.runtime.currentThemeName const [uniwindState, recreate] = useReducer( () => UniwindStore.getStyles(className, undefined, undefined, uniwindContext), undefined, @@ -19,11 +20,13 @@ export const useResolveClassNames = (className: string) => { useLayoutEffect(() => { if (uniwindState.dependencies.length > 0) { - const dispose = UniwindListener.subscribe(recreate, uniwindState.dependencies) + const dispose = UniwindListener.subscribe(recreate, uniwindState.dependencies, { + shouldNotifyVariables: (theme) => theme === effectiveTheme, + }) return dispose } - }, [uniwindState.dependencySum, className]) + }, [uniwindState.dependencySum, className, effectiveTheme]) return uniwindState.styles } diff --git a/packages/uniwind/tests/native/components/scoped-theme.test.tsx b/packages/uniwind/tests/native/components/scoped-theme.test.tsx index f3e6004e..d0fa393e 100644 --- a/packages/uniwind/tests/native/components/scoped-theme.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-theme.test.tsx @@ -175,6 +175,47 @@ describe('ScopedTheme', () => { expect(nestedLightInDark).toHaveBeenLastCalledWith('#ffffff') }) + test('useCSSVariable rerenders only affected custom scoped theme', () => { + const base = jest.fn() + const nestedCustom = jest.fn() + + const Component = (props: { test: jest.Mock }) => { + const backgroundColor = useCSSVariable('--color-background') + + props.test(backgroundColor) + + return null + } + + act(() => { + Uniwind.updateCSSVariables('custom', { '--color-background': '#123456' }) + }) + + renderUniwind( + + + + + + , + ) + + expect(Uniwind.currentTheme).toEqual('light') + expect(base).toHaveBeenCalledTimes(1) + expect(nestedCustom).toHaveBeenCalledTimes(1) + expect(base).toHaveBeenLastCalledWith('#ffffff') + expect(nestedCustom).toHaveBeenLastCalledWith('#123456') + + act(() => { + Uniwind.updateCSSVariables('custom', { '--color-background': '#112233' }) + }) + + expect(base).toHaveBeenCalledTimes(1) + expect(nestedCustom).toHaveBeenCalledTimes(2) + expect(base).toHaveBeenLastCalledWith('#ffffff') + expect(nestedCustom).toHaveBeenLastCalledWith('#112233') + }) + test('useUniwind', () => { const base = jest.fn() const nestedDark = jest.fn() diff --git a/packages/uniwind/tests/setup.native.ts b/packages/uniwind/tests/setup.native.ts index 002bb3a8..4f01a5c9 100644 --- a/packages/uniwind/tests/setup.native.ts +++ b/packages/uniwind/tests/setup.native.ts @@ -15,13 +15,13 @@ beforeAll(async () => { cssPath, debug: true, platform: Platform.iOS, - themes: ['light', 'dark'], + themes: ['light', 'dark', 'custom'], polyfills: undefined, }) eval( `const { Uniwind } = require('../src/core/config/config.native'); - Uniwind.__reinit(rt => ${virtualCode}, ['light', 'dark']); + Uniwind.__reinit(rt => ${virtualCode}, ['light', 'dark', 'custom']); Uniwind.updateInsets({ top: ${SAFE_AREA_INSET_TOP}, left: 0, diff --git a/packages/uniwind/tests/web/scoped-theme-css-variables.test.tsx b/packages/uniwind/tests/web/scoped-theme-css-variables.test.tsx new file mode 100644 index 00000000..a14ab5f9 --- /dev/null +++ b/packages/uniwind/tests/web/scoped-theme-css-variables.test.tsx @@ -0,0 +1,58 @@ +import { act, render } from '@testing-library/react' +import * as React from 'react' +import { ScopedTheme } from '../../src/components/ScopedTheme/ScopedTheme' +import { Uniwind } from '../../src/core/config/config' +import { getWebVariable } from '../../src/core/web/getWebStyles' +import { useCSSVariable } from '../../src/hooks/useCSSVariable' + +describe('Scoped theme + CSS variables (web)', () => { + afterEach(() => { + Uniwind.setTheme('light') + document.documentElement.removeAttribute('style') + }) + + test('getWebVariable reads runtime update for scoped theme while global theme stays light', () => { + Uniwind.setTheme('light') + Uniwind.updateCSSVariables('custom', { '--test-scoped-web-a': '#aabbcc' }) + + const value = getWebVariable('--test-scoped-web-a', { scopedTheme: 'custom' }) + + expect(value).toMatch(/^#aabbcc$/i) + }) + + test('useCSSVariable rerenders only affected custom scoped theme', () => { + const base = jest.fn() + const nestedScopedTestTheme = jest.fn() + + const Component = (props: { test: jest.Mock }) => { + const v = useCSSVariable('--test-scoped-web-b') + + props.test(v) + + return null + } + + Uniwind.setTheme('light') + + render( + + + + + + , + ) + + expect(Uniwind.currentTheme).toEqual('light') + expect(base).toHaveBeenCalledTimes(1) + expect(nestedScopedTestTheme).toHaveBeenCalledTimes(1) + + act(() => { + Uniwind.updateCSSVariables('custom', { '--test-scoped-web-b': '#ddeeff' }) + }) + + expect(base).toHaveBeenCalledTimes(1) + expect(nestedScopedTestTheme).toHaveBeenCalledTimes(2) + expect(nestedScopedTestTheme.mock.calls[1][0]).toMatch(/^#ddeeff$/i) + }) +})