Skip to content

Commit 1f90dd0

Browse files
authored
fix: updateCSSVariable inside scoped theme (#502)
* fix: updateCSSVariable scoped web * fix: updateCSSVariables scoped on native * test: updateCSSVariable scoped native * chore: clear web cssRules cache on reinit * test: updateCSSVariables web
1 parent e0c91ee commit 1f90dd0

7 files changed

Lines changed: 274 additions & 42 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== undefined && value !== null
2+
3+
export const arrayEquals = <T>(a: Array<T>, b: Array<T>) => {
4+
if (a.length !== b.length) {
5+
return false
6+
}
7+
8+
return a.every((value, index) => value === b[index])
9+
}

packages/uniwind/src/core/config/config.native.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
4444
})
4545
})
4646

47-
if (theme === this.currentTheme) {
48-
UniwindListener.notify([StyleDependency.Variables])
49-
}
47+
UniwindListener.notify([StyleDependency.Variables])
5048
}
5149

5250
updateInsets(insets: Insets) {

packages/uniwind/src/core/config/config.ts

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,98 @@
1+
import { arrayEquals } from '../../common/utils'
12
import { StyleDependency } from '../../types'
23
import { UniwindListener } from '../listener'
34
import { Logger } from '../logger'
4-
import { CSSVariables, ThemeName } from '../types'
5+
import { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../types'
6+
import { getWebVariable } from '../web'
57
import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common'
68

9+
type UniwindCSSRule = {
10+
style: CSSStyleDeclaration
11+
theme: ThemeName
12+
}
13+
714
class UniwindConfigBuilder extends UniwindConfigBuilderBase {
8-
private runtimeCSSVariables = new Map<ThemeName, CSSVariables>()
15+
private cssRules?: Array<UniwindCSSRule>
916

1017
constructor() {
1118
super()
1219
}
1320

1421
updateCSSVariables(theme: ThemeName, variables: CSSVariables) {
22+
if (typeof document === 'undefined') {
23+
return
24+
}
25+
26+
const uniwindRules = this.getUniwindDynamicCSSRules()
27+
1528
Object.entries(variables).forEach(([varName, varValue]) => {
1629
if (!varName.startsWith('--') && __DEV__) {
1730
Logger.error(`CSS variable name must start with "--", instead got: ${varName}`)
18-
19-
return
2031
}
2132

22-
const runtimeCSSVariables = this.runtimeCSSVariables.get(theme) ?? {}
33+
const existingRules: Record<ThemeName, string | undefined> = Object.fromEntries(
34+
uniwindRules.map(rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme })]),
35+
)
2336

24-
runtimeCSSVariables[varName] = varValue
25-
this.runtimeCSSVariables.set(theme, runtimeCSSVariables)
37+
uniwindRules.forEach(rule => {
38+
if (rule.theme === theme) {
39+
rule.style.setProperty(
40+
varName,
41+
typeof varValue === 'number' ? `${varValue}px` : varValue,
42+
)
2643

27-
if (theme === this.currentTheme) {
28-
this.applyCSSVariable(varName, varValue)
29-
}
44+
return
45+
}
46+
47+
rule.style.setProperty(varName, existingRules[rule.theme] ?? null)
48+
})
3049
})
3150

32-
if (theme === this.currentTheme) {
33-
UniwindListener.notify([StyleDependency.Variables])
34-
}
51+
UniwindListener.notify([StyleDependency.Variables])
3552
}
3653

37-
protected onThemeChange() {
38-
if (typeof document === 'undefined') {
54+
protected __reinit(generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array<string>) {
55+
const oldThemes = this.themes
56+
super.__reinit(generateStyleSheetCallback, themes)
57+
58+
if (arrayEquals(themes, oldThemes)) {
3959
return
4060
}
4161

42-
document.documentElement.removeAttribute('style')
43-
44-
const runtimeCSSVariables = this.runtimeCSSVariables.get(this.currentTheme)
62+
this.cssRules = undefined
4563

46-
if (!runtimeCSSVariables) {
47-
return
64+
if (typeof document !== 'undefined') {
65+
document.querySelector('#uniwind-dynamic-styles')?.remove()
4866
}
49-
50-
Object.entries(runtimeCSSVariables).forEach(([varName, varValue]) => {
51-
this.applyCSSVariable(varName, varValue)
52-
})
5367
}
5468

55-
private applyCSSVariable(varName: keyof CSSVariables, varValue: CSSVariables[keyof CSSVariables]) {
69+
private getUniwindDynamicCSSRules() {
70+
if (this.cssRules) {
71+
return this.cssRules
72+
}
73+
5674
if (typeof document === 'undefined') {
57-
return
75+
return []
5876
}
5977

60-
document.documentElement.style.setProperty(
61-
varName,
62-
typeof varValue === 'number' ? `${varValue}px` : varValue,
78+
const styleElement = document.createElement('style')
79+
80+
styleElement.innerText = this.themes.reduce(
81+
(acc, theme) => {
82+
return `${acc}.${theme}{}`
83+
},
84+
'',
6385
)
86+
styleElement.setAttribute('id', 'uniwind-dynamic-styles')
87+
document.head.appendChild(styleElement)
88+
89+
const cssRules = Array.from(styleElement.sheet?.cssRules ?? [])
90+
.filter((rule): rule is CSSStyleRule => 'selectorText' in rule && 'style' in rule)
91+
.map((rule): UniwindCSSRule => ({ style: rule.style, theme: rule.selectorText.replace('.', '') }))
92+
93+
this.cssRules = cssRules
94+
95+
return cssRules
6496
}
6597
}
6698

packages/uniwind/src/core/web/cssListener.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class CSSListenerBuilder {
6464
disposables.push(() => listeners?.delete(listener))
6565
})
6666

67-
const disposeThemeListener = UniwindListener.subscribe(listener, [StyleDependency.Theme])
67+
const disposeThemeListener = UniwindListener.subscribe(listener, [StyleDependency.Theme, StyleDependency.Variables])
6868

6969
return () => {
7070
disposables.forEach(disposable => disposable())

packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useLayoutEffect, useRef, useState } from 'react'
2+
import { arrayEquals } from '../../common/utils'
23
import { useUniwindContext } from '../../core/context'
34
import { UniwindListener } from '../../core/listener'
45
import { Logger } from '../../core/logger'
@@ -11,14 +12,6 @@ const getValue = (name: string | Array<string>, uniwindContext: UniwindContextTy
1112
? name.map(name => getVariableValue(name, uniwindContext))
1213
: getVariableValue(name, uniwindContext)
1314

14-
const arrayEquals = <T>(a: Array<T>, b: Array<T>) => {
15-
if (a.length !== b.length) {
16-
return false
17-
}
18-
19-
return a.every((value, index) => value === b[index])
20-
}
21-
2215
let warned = false
2316

2417
const logDevError = (name: string) => {

packages/uniwind/tests/native/components/scoped-theme.test.tsx

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import { renderUniwind } from '../utils'
1313

1414
describe('ScopedTheme', () => {
1515
afterEach(() => {
16-
Uniwind.setTheme('light')
16+
act(() => {
17+
Uniwind.setTheme('light')
18+
Uniwind.updateCSSVariables('light', { '--color-background': '#ffffff' })
19+
Uniwind.updateCSSVariables('dark', { '--color-background': '#000000' })
20+
})
1721
})
1822

1923
test('Component styles', () => {
@@ -212,4 +216,137 @@ describe('ScopedTheme', () => {
212216
expect(nestedDark).toHaveBeenLastCalledWith('dark')
213217
expect(nestedLightInDark).toHaveBeenLastCalledWith('light')
214218
})
219+
220+
describe('updateCSSVariables', () => {
221+
test('Component styles', () => {
222+
const { getStylesFromId } = renderUniwind(
223+
<React.Fragment>
224+
<View className="bg-background" testID="base" />
225+
<ScopedTheme theme="dark">
226+
<View className="bg-background" testID="nested-dark" />
227+
<ScopedTheme theme="light">
228+
<View className="bg-background" testID="nested-light-in-dark" />
229+
</ScopedTheme>
230+
</ScopedTheme>
231+
</React.Fragment>,
232+
)
233+
234+
expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff')
235+
expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#000000')
236+
expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff')
237+
238+
act(() => {
239+
Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' })
240+
})
241+
242+
expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff')
243+
expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#123456')
244+
expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff')
245+
})
246+
247+
test('withUniwind', () => {
248+
const Component: React.FC<ActivityIndicatorProps> = (props) => <ActivityIndicator {...props} />
249+
const WithUniwind = withUniwind(Component)
250+
251+
const { getStylesFromId } = renderUniwind(
252+
<React.Fragment>
253+
<WithUniwind className="bg-background" testID="base" />
254+
<ScopedTheme theme="dark">
255+
<WithUniwind className="bg-background" testID="nested-dark" />
256+
<ScopedTheme theme="light">
257+
<WithUniwind className="bg-background" testID="nested-light-in-dark" />
258+
</ScopedTheme>
259+
</ScopedTheme>
260+
</React.Fragment>,
261+
)
262+
263+
expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff')
264+
expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#000000')
265+
expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff')
266+
267+
act(() => {
268+
Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' })
269+
})
270+
271+
expect(getStylesFromId('base').backgroundColor).toEqual('#ffffff')
272+
expect(getStylesFromId('nested-dark').backgroundColor).toEqual('#123456')
273+
expect(getStylesFromId('nested-light-in-dark').backgroundColor).toEqual('#ffffff')
274+
})
275+
276+
test('useResolveClassNames', () => {
277+
const base = jest.fn()
278+
const nestedDark = jest.fn()
279+
const nestedLightInDark = jest.fn()
280+
281+
const Component = (props: { test: jest.Mock }) => {
282+
const { backgroundColor } = useResolveClassNames('bg-background')
283+
284+
props.test(backgroundColor)
285+
286+
return null
287+
}
288+
289+
renderUniwind(
290+
<React.Fragment>
291+
<Component test={base} />
292+
<ScopedTheme theme="dark">
293+
<Component test={nestedDark} />
294+
<ScopedTheme theme="light">
295+
<Component test={nestedLightInDark} />
296+
</ScopedTheme>
297+
</ScopedTheme>
298+
</React.Fragment>,
299+
)
300+
301+
expect(base).toHaveBeenCalledWith('#ffffff')
302+
expect(nestedDark).toHaveBeenCalledWith('#000000')
303+
expect(nestedLightInDark).toHaveBeenCalledWith('#ffffff')
304+
305+
act(() => {
306+
Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' })
307+
})
308+
309+
expect(base).toHaveBeenLastCalledWith('#ffffff')
310+
expect(nestedDark).toHaveBeenLastCalledWith('#123456')
311+
expect(nestedLightInDark).toHaveBeenLastCalledWith('#ffffff')
312+
})
313+
314+
test('useCSSVariable', () => {
315+
const base = jest.fn()
316+
const nestedDark = jest.fn()
317+
const nestedLightInDark = jest.fn()
318+
319+
const Component = (props: { test: jest.Mock }) => {
320+
const backgroundColor = useCSSVariable('--color-background')
321+
322+
props.test(backgroundColor)
323+
324+
return null
325+
}
326+
327+
renderUniwind(
328+
<React.Fragment>
329+
<Component test={base} />
330+
<ScopedTheme theme="dark">
331+
<Component test={nestedDark} />
332+
<ScopedTheme theme="light">
333+
<Component test={nestedLightInDark} />
334+
</ScopedTheme>
335+
</ScopedTheme>
336+
</React.Fragment>,
337+
)
338+
339+
expect(base).toHaveBeenCalledWith('#ffffff')
340+
expect(nestedDark).toHaveBeenCalledWith('#000000')
341+
expect(nestedLightInDark).toHaveBeenCalledWith('#ffffff')
342+
343+
act(() => {
344+
Uniwind.updateCSSVariables('dark', { '--color-background': '#123456' })
345+
})
346+
347+
expect(base).toHaveBeenLastCalledWith('#ffffff')
348+
expect(nestedDark).toHaveBeenLastCalledWith('#123456')
349+
expect(nestedLightInDark).toHaveBeenLastCalledWith('#ffffff')
350+
})
351+
})
215352
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Uniwind } from '../../../src/core/config/config'
2+
3+
type UniwindForTest = {
4+
updateCSSVariables: typeof Uniwind.updateCSSVariables
5+
__reinit: (_: () => {}, themes: Array<string>) => void
6+
cssRules?: Array<unknown>
7+
}
8+
9+
const uniwind = Uniwind as unknown as UniwindForTest
10+
11+
const getDynamicStyleElement = () => document.getElementById('uniwind-dynamic-styles') as HTMLStyleElement | null
12+
13+
const getDynamicRules = () =>
14+
Array.from(getDynamicStyleElement()?.sheet?.cssRules ?? [])
15+
.filter((rule): rule is CSSStyleRule => 'selectorText' in rule && 'style' in rule)
16+
17+
const getRule = (selectorText: string) => getDynamicRules().find(rule => rule.selectorText === selectorText)
18+
19+
const resetUniwind = (themes: Array<string> = ['light', 'dark']) => {
20+
uniwind.cssRules = undefined
21+
getDynamicStyleElement()?.remove()
22+
uniwind.__reinit(() => ({}), themes)
23+
}
24+
25+
describe('Uniwind web config', () => {
26+
beforeAll(() => {
27+
Object.defineProperty(HTMLStyleElement.prototype, 'innerText', {
28+
configurable: true,
29+
get() {
30+
return this.textContent ?? ''
31+
},
32+
set(value: string) {
33+
this.textContent = value
34+
},
35+
})
36+
})
37+
38+
beforeEach(() => {
39+
resetUniwind()
40+
})
41+
42+
afterEach(() => {
43+
resetUniwind()
44+
})
45+
46+
test('updateCSSVariables creates scoped rules', () => {
47+
uniwind.updateCSSVariables('dark', { '--color-background': '#123456' })
48+
49+
expect(getDynamicStyleElement()).not.toBeNull()
50+
expect(getDynamicRules().map(rule => rule.selectorText)).toEqual(['.light', '.dark'])
51+
expect(getRule('.dark')?.style.getPropertyValue('--color-background')).toBe('#123456')
52+
})
53+
54+
test('__reinit rebuilds dynamic rules when themes change', () => {
55+
uniwind.updateCSSVariables('dark', { '--color-background': '#123456' })
56+
57+
uniwind.__reinit(() => ({}), ['light', 'dark', 'premium'])
58+
uniwind.updateCSSVariables('premium', { '--color-background': '#abcdef' })
59+
60+
expect(document.querySelectorAll('#uniwind-dynamic-styles')).toHaveLength(1)
61+
expect(getDynamicRules().map(rule => rule.selectorText)).toEqual(['.light', '.dark', '.premium'])
62+
expect(getRule('.premium')?.style.getPropertyValue('--color-background')).toBe('#abcdef')
63+
})
64+
})

0 commit comments

Comments
 (0)