diff --git a/packages/uniwind/src/common/utils.ts b/packages/uniwind/src/common/utils.ts index b5a22b32..317fa2db 100644 --- a/packages/uniwind/src/common/utils.ts +++ b/packages/uniwind/src/common/utils.ts @@ -1 +1,9 @@ export const isDefined = (value: T): value is NonNullable => value !== undefined && value !== null + +export const arrayEquals = (a: Array, b: Array) => { + if (a.length !== b.length) { + return false + } + + return a.every((value, index) => value === b[index]) +} diff --git a/packages/uniwind/src/core/config/config.native.ts b/packages/uniwind/src/core/config/config.native.ts index 3c51bc19..29d91d62 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.notify([StyleDependency.Variables]) } updateInsets(insets: Insets) { diff --git a/packages/uniwind/src/core/config/config.ts b/packages/uniwind/src/core/config/config.ts index b659fa59..e2b21f42 100644 --- a/packages/uniwind/src/core/config/config.ts +++ b/packages/uniwind/src/core/config/config.ts @@ -1,66 +1,98 @@ +import { arrayEquals } from '../../common/utils' import { StyleDependency } from '../../types' import { UniwindListener } from '../listener' import { Logger } from '../logger' -import { CSSVariables, ThemeName } from '../types' +import { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../types' +import { getWebVariable } from '../web' import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common' +type UniwindCSSRule = { + style: CSSStyleDeclaration + theme: ThemeName +} + class UniwindConfigBuilder extends UniwindConfigBuilderBase { - private runtimeCSSVariables = new Map() + private cssRules?: Array constructor() { super() } updateCSSVariables(theme: ThemeName, variables: CSSVariables) { + if (typeof document === 'undefined') { + return + } + + const uniwindRules = this.getUniwindDynamicCSSRules() + Object.entries(variables).forEach(([varName, varValue]) => { if (!varName.startsWith('--') && __DEV__) { Logger.error(`CSS variable name must start with "--", instead got: ${varName}`) - - return } - const runtimeCSSVariables = this.runtimeCSSVariables.get(theme) ?? {} + const existingRules: Record = Object.fromEntries( + uniwindRules.map(rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme })]), + ) - runtimeCSSVariables[varName] = varValue - this.runtimeCSSVariables.set(theme, runtimeCSSVariables) + uniwindRules.forEach(rule => { + if (rule.theme === theme) { + rule.style.setProperty( + varName, + typeof varValue === 'number' ? `${varValue}px` : varValue, + ) - if (theme === this.currentTheme) { - this.applyCSSVariable(varName, varValue) - } + return + } + + rule.style.setProperty(varName, existingRules[rule.theme] ?? null) + }) }) - if (theme === this.currentTheme) { - UniwindListener.notify([StyleDependency.Variables]) - } + UniwindListener.notify([StyleDependency.Variables]) } - protected onThemeChange() { - if (typeof document === 'undefined') { + protected __reinit(generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) { + const oldThemes = this.themes + super.__reinit(generateStyleSheetCallback, themes) + + if (arrayEquals(themes, oldThemes)) { return } - document.documentElement.removeAttribute('style') - - const runtimeCSSVariables = this.runtimeCSSVariables.get(this.currentTheme) + this.cssRules = undefined - if (!runtimeCSSVariables) { - return + if (typeof document !== 'undefined') { + document.querySelector('#uniwind-dynamic-styles')?.remove() } - - Object.entries(runtimeCSSVariables).forEach(([varName, varValue]) => { - this.applyCSSVariable(varName, varValue) - }) } - private applyCSSVariable(varName: keyof CSSVariables, varValue: CSSVariables[keyof CSSVariables]) { + private getUniwindDynamicCSSRules() { + if (this.cssRules) { + return this.cssRules + } + if (typeof document === 'undefined') { - return + return [] } - document.documentElement.style.setProperty( - varName, - typeof varValue === 'number' ? `${varValue}px` : varValue, + const styleElement = document.createElement('style') + + styleElement.innerText = this.themes.reduce( + (acc, theme) => { + return `${acc}.${theme}{}` + }, + '', ) + styleElement.setAttribute('id', 'uniwind-dynamic-styles') + document.head.appendChild(styleElement) + + const cssRules = Array.from(styleElement.sheet?.cssRules ?? []) + .filter((rule): rule is CSSStyleRule => 'selectorText' in rule && 'style' in rule) + .map((rule): UniwindCSSRule => ({ style: rule.style, theme: rule.selectorText.replace('.', '') })) + + this.cssRules = cssRules + + return cssRules } } diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 36c3e923..ffdbb57d 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -64,7 +64,7 @@ class CSSListenerBuilder { disposables.push(() => listeners?.delete(listener)) }) - const disposeThemeListener = UniwindListener.subscribe(listener, [StyleDependency.Theme]) + const disposeThemeListener = UniwindListener.subscribe(listener, [StyleDependency.Theme, StyleDependency.Variables]) return () => { disposables.forEach(disposable => disposable()) diff --git a/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts index 6cc09a41..1c21db27 100644 --- a/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts +++ b/packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts @@ -1,4 +1,5 @@ import { useLayoutEffect, useRef, useState } from 'react' +import { arrayEquals } from '../../common/utils' import { useUniwindContext } from '../../core/context' import { UniwindListener } from '../../core/listener' import { Logger } from '../../core/logger' @@ -11,14 +12,6 @@ const getValue = (name: string | Array, uniwindContext: UniwindContextTy ? name.map(name => getVariableValue(name, uniwindContext)) : getVariableValue(name, uniwindContext) -const arrayEquals = (a: Array, b: Array) => { - if (a.length !== b.length) { - return false - } - - return a.every((value, index) => value === b[index]) -} - let warned = false const logDevError = (name: string) => { diff --git a/packages/uniwind/tests/native/components/scoped-theme.test.tsx b/packages/uniwind/tests/native/components/scoped-theme.test.tsx index f3e6004e..be0b8048 100644 --- a/packages/uniwind/tests/native/components/scoped-theme.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-theme.test.tsx @@ -13,7 +13,11 @@ import { renderUniwind } from '../utils' describe('ScopedTheme', () => { afterEach(() => { - Uniwind.setTheme('light') + act(() => { + Uniwind.setTheme('light') + Uniwind.updateCSSVariables('light', { '--color-background': '#ffffff' }) + Uniwind.updateCSSVariables('dark', { '--color-background': '#000000' }) + }) }) test('Component styles', () => { @@ -212,4 +216,137 @@ describe('ScopedTheme', () => { expect(nestedDark).toHaveBeenLastCalledWith('dark') expect(nestedLightInDark).toHaveBeenLastCalledWith('light') }) + + describe('updateCSSVariables', () => { + test('Component styles', () => { + const { getStylesFromId } = renderUniwind( + + + + + + + + + , + ) + + expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff') + expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#000000') + expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff') + + act(() => { + Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' }) + }) + + expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff') + expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#123456') + expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff') + }) + + test('withUniwind', () => { + const Component: React.FC = (props) => + const WithUniwind = withUniwind(Component) + + const { getStylesFromId } = renderUniwind( + + + + + + + + + , + ) + + expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff') + expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#000000') + expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff') + + act(() => { + Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' }) + }) + + expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff') + expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#123456') + expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff') + }) + + test('useResolveClassNames', () => { + const base = jest.fn() + const nestedDark = jest.fn() + const nestedLightInDark = jest.fn() + + const Component = (props: { test: jest.Mock }) => { + const { backgroundColor } = useResolveClassNames('bg-background') + + props.test(backgroundColor) + + return null + } + + renderUniwind( + + + + + + + + + , + ) + + expect(base).toHaveBeenCalledWith('#ffffff') + expect(nestedDark).toHaveBeenCalledWith('#000000') + expect(nestedLightInDark).toHaveBeenCalledWith('#ffffff') + + act(() => { + Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' }) + }) + + expect(base).toHaveBeenLastCalledWith('#ffffff') + expect(nestedDark).toHaveBeenLastCalledWith('#123456') + expect(nestedLightInDark).toHaveBeenLastCalledWith('#ffffff') + }) + + test('useCSSVariable', () => { + const base = jest.fn() + const nestedDark = jest.fn() + const nestedLightInDark = jest.fn() + + const Component = (props: { test: jest.Mock }) => { + const backgroundColor = useCSSVariable('--color-background') + + props.test(backgroundColor) + + return null + } + + renderUniwind( + + + + + + + + + , + ) + + expect(base).toHaveBeenCalledWith('#ffffff') + expect(nestedDark).toHaveBeenCalledWith('#000000') + expect(nestedLightInDark).toHaveBeenCalledWith('#ffffff') + + act(() => { + Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' }) + }) + + expect(base).toHaveBeenLastCalledWith('#ffffff') + expect(nestedDark).toHaveBeenLastCalledWith('#123456') + expect(nestedLightInDark).toHaveBeenLastCalledWith('#ffffff') + }) + }) }) diff --git a/packages/uniwind/tests/web/core/config.test.ts b/packages/uniwind/tests/web/core/config.test.ts new file mode 100644 index 00000000..26169168 --- /dev/null +++ b/packages/uniwind/tests/web/core/config.test.ts @@ -0,0 +1,64 @@ +import { Uniwind } from '../../../src/core/config/config' + +type UniwindForTest = { + updateCSSVariables: typeof Uniwind.updateCSSVariables + __reinit: (_: () => {}, themes: Array) => void + cssRules?: Array +} + +const uniwind = Uniwind as unknown as UniwindForTest + +const getDynamicStyleElement = () => document.getElementById('uniwind-dynamic-styles') as HTMLStyleElement | null + +const getDynamicRules = () => + Array.from(getDynamicStyleElement()?.sheet?.cssRules ?? []) + .filter((rule): rule is CSSStyleRule => 'selectorText' in rule && 'style' in rule) + +const getRule = (selectorText: string) => getDynamicRules().find(rule => rule.selectorText === selectorText) + +const resetUniwind = (themes: Array = ['light', 'dark']) => { + uniwind.cssRules = undefined + getDynamicStyleElement()?.remove() + uniwind.__reinit(() => ({}), themes) +} + +describe('Uniwind web config', () => { + beforeAll(() => { + Object.defineProperty(HTMLStyleElement.prototype, 'innerText', { + configurable: true, + get() { + return this.textContent ?? '' + }, + set(value: string) { + this.textContent = value + }, + }) + }) + + beforeEach(() => { + resetUniwind() + }) + + afterEach(() => { + resetUniwind() + }) + + test('updateCSSVariables creates scoped rules', () => { + uniwind.updateCSSVariables('dark', { '--color-background': '#123456' }) + + expect(getDynamicStyleElement()).not.toBeNull() + expect(getDynamicRules().map(rule => rule.selectorText)).toEqual(['.light', '.dark']) + expect(getRule('.dark')?.style.getPropertyValue('--color-background')).toBe('#123456') + }) + + test('__reinit rebuilds dynamic rules when themes change', () => { + uniwind.updateCSSVariables('dark', { '--color-background': '#123456' }) + + uniwind.__reinit(() => ({}), ['light', 'dark', 'premium']) + uniwind.updateCSSVariables('premium', { '--color-background': '#abcdef' }) + + expect(document.querySelectorAll('#uniwind-dynamic-styles')).toHaveLength(1) + expect(getDynamicRules().map(rule => rule.selectorText)).toEqual(['.light', '.dark', '.premium']) + expect(getRule('.premium')?.style.getPropertyValue('--color-background')).toBe('#abcdef') + }) +})