Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/uniwind/src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== undefined && value !== null

export const arrayEquals = <T>(a: Array<T>, b: Array<T>) => {
if (a.length !== b.length) {
return false
}

return a.every((value, index) => value === b[index])
}
4 changes: 1 addition & 3 deletions packages/uniwind/src/core/config/config.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
})
})

if (theme === this.currentTheme) {
UniwindListener.notify([StyleDependency.Variables])
}
UniwindListener.notify([StyleDependency.Variables])
}

updateInsets(insets: Insets) {
Expand Down
90 changes: 61 additions & 29 deletions packages/uniwind/src/core/config/config.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeName, CSSVariables>()
private cssRules?: Array<UniwindCSSRule>

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<ThemeName, string | undefined> = 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<string>) {
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('.', '') }))
Comment thread
Brentlok marked this conversation as resolved.

this.cssRules = cssRules

return cssRules
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/uniwind/src/core/web/cssListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
9 changes: 1 addition & 8 deletions packages/uniwind/src/hooks/useCSSVariable/useCSSVariable.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,14 +12,6 @@ const getValue = (name: string | Array<string>, uniwindContext: UniwindContextTy
? name.map(name => getVariableValue(name, uniwindContext))
: getVariableValue(name, uniwindContext)

const arrayEquals = <T>(a: Array<T>, b: Array<T>) => {
if (a.length !== b.length) {
return false
}

return a.every((value, index) => value === b[index])
}

let warned = false

const logDevError = (name: string) => {
Expand Down
139 changes: 138 additions & 1 deletion packages/uniwind/tests/native/components/scoped-theme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -212,4 +216,137 @@ describe('ScopedTheme', () => {
expect(nestedDark).toHaveBeenLastCalledWith('dark')
expect(nestedLightInDark).toHaveBeenLastCalledWith('light')
})

describe('updateCSSVariables', () => {
test('Component styles', () => {
const { getStylesFromId } = renderUniwind(
<React.Fragment>
<View className="bg-background" testID="base" />
<ScopedTheme theme="dark">
<View className="bg-background" testID="nested-dark" />
<ScopedTheme theme="light">
<View className="bg-background" testID="nested-light-in-dark" />
</ScopedTheme>
</ScopedTheme>
</React.Fragment>,
)

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<ActivityIndicatorProps> = (props) => <ActivityIndicator {...props} />
const WithUniwind = withUniwind(Component)

const { getStylesFromId } = renderUniwind(
<React.Fragment>
<WithUniwind className="bg-background" testID="base" />
<ScopedTheme theme="dark">
<WithUniwind className="bg-background" testID="nested-dark" />
<ScopedTheme theme="light">
<WithUniwind className="bg-background" testID="nested-light-in-dark" />
</ScopedTheme>
</ScopedTheme>
</React.Fragment>,
)

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(
<React.Fragment>
<Component test={base} />
<ScopedTheme theme="dark">
<Component test={nestedDark} />
<ScopedTheme theme="light">
<Component test={nestedLightInDark} />
</ScopedTheme>
</ScopedTheme>
</React.Fragment>,
)

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(
<React.Fragment>
<Component test={base} />
<ScopedTheme theme="dark">
<Component test={nestedDark} />
<ScopedTheme theme="light">
<Component test={nestedLightInDark} />
</ScopedTheme>
</ScopedTheme>
</React.Fragment>,
)

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')
})
})
})
64 changes: 64 additions & 0 deletions packages/uniwind/tests/web/core/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Uniwind } from '../../../src/core/config/config'

type UniwindForTest = {
updateCSSVariables: typeof Uniwind.updateCSSVariables
__reinit: (_: () => {}, themes: Array<string>) => void
cssRules?: Array<unknown>
}

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<string> = ['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')
})
})