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
19 changes: 19 additions & 0 deletions packages/uniwind/src/core/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ type SubscribeOptions = {
}

class UniwindListenerBuilder {
private revisions = {
[StyleDependency.ColorScheme]: 0,
[StyleDependency.Theme]: 0,
[StyleDependency.Dimensions]: 0,
[StyleDependency.Orientation]: 0,
[StyleDependency.Insets]: 0,
[StyleDependency.FontScale]: 0,
[StyleDependency.Rtl]: 0,
[StyleDependency.AdaptiveThemes]: 0,
[StyleDependency.Variables]: 0,
}
private listeners = {
[StyleDependency.ColorScheme]: new Set<() => void>(),
[StyleDependency.Theme]: new Set<() => void>(),
Expand All @@ -17,13 +28,21 @@ class UniwindListenerBuilder {
[StyleDependency.Variables]: new Set<() => void>(),
}

getSnapshot = (dependencies: Array<StyleDependency>) =>
dependencies.reduce(
(snapshot, dependency) => snapshot + this.revisions[dependency],
0,
)

notify(dependencies: Array<StyleDependency>) {
dependencies.forEach(dep => {
this.revisions[dep]++
this.listeners[dep].forEach(callback => callback())
})
}

notifyAll() {
Object.keys(this.revisions).forEach(dep => this.revisions[Number(dep) as StyleDependency]++)
Object.values(this.listeners).forEach(listenerSet => {
listenerSet.forEach(callback => callback())
})
Expand Down
12 changes: 12 additions & 0 deletions packages/uniwind/src/core/web/cssListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ class CSSListenerBuilder {
})
}

getSnapshot(classNames: string) {
const mediaQueries = new Set(
classNames
.split(' ')
.map(className => this.classNameMediaQueryListeners.get(className))
.filter(mediaQuery => mediaQuery !== undefined),
)
const themeSnapshot = UniwindListener.getSnapshot([StyleDependency.Theme, StyleDependency.Variables])

return `${themeSnapshot}:${Array.from(mediaQueries).map(mediaQuery => Number(mediaQuery.matches)).join('')}`
}

subscribeToClassName(classNames: string, listener: VoidFunction) {
const disposables = [] as Array<VoidFunction>

Expand Down
44 changes: 21 additions & 23 deletions packages/uniwind/src/hoc/withUniwind.native.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentProps } from 'react'
import { useLayoutEffect, useReducer } from 'react'
import { useCallback, useSyncExternalStore } from 'react'
import type { StyleDependency } from '../common/consts'
import { isDefined } from '../common/utils'
import { useUniwindContext } from '../core/context'
Expand All @@ -11,6 +11,24 @@ import { classToColor, classToStyle, isClassProperty, isColorClassProperty, isSt

let warnedOnce = false

const useDependencies = (dependencies: Array<StyleDependency>) => {
const uniqueDependencies = Array.from(new Set(dependencies))
const dependencySum = uniqueDependencies.reduce((acc, dependency) => {
acc |= 1 << dependency
return acc
}, 0)
const subscribe = useCallback(
(callback: () => void) => UniwindListener.subscribe(callback, uniqueDependencies),
[dependencySum],
)
const getSnapshot = useCallback(
() => UniwindListener.getSnapshot(uniqueDependencies),
[dependencySum],
)

useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}

export const withUniwind: WithUniwind = <
TComponent extends Component,
TOptions extends Record<keyof ComponentProps<TComponent>, OptionMapping>,
Expand Down Expand Up @@ -67,17 +85,7 @@ const withAutoUniwind = (Component: Component<AnyObject>) => (props: AnyObject)
return acc
}, { generatedProps: {} as AnyObject, dependencies: [] as Array<StyleDependency> })

const dependencySum = dependencies.reduce((acc, dependency) => {
acc |= 1 << dependency
return acc
}, 0)
const [, rerender] = useReducer(() => ({}), {})

useLayoutEffect(() => {
const dispose = UniwindListener.subscribe(rerender, Array.from(new Set(dependencies)))

return dispose
}, [dependencySum])
useDependencies(dependencies)

return (
<Component
Expand Down Expand Up @@ -132,17 +140,7 @@ const withManualUniwind = (Component: Component<AnyObject>, options: Record<Prop
return acc
}, { generatedProps: {} as AnyObject, dependencies: [] as Array<StyleDependency> })

const dependencySum = dependencies.reduce((acc, dependency) => {
acc |= 1 << dependency
return acc
}, 0)
const [, rerender] = useReducer(() => ({}), {})

useLayoutEffect(() => {
const dispose = UniwindListener.subscribe(rerender, Array.from(new Set(dependencies)))

return dispose
}, [dependencySum])
useDependencies(dependencies)

return (
<Component
Expand Down
31 changes: 16 additions & 15 deletions packages/uniwind/src/hoc/withUniwind.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentProps } from 'react'
import { useLayoutEffect, useReducer } from 'react'
import { useCallback, useSyncExternalStore } from 'react'
import { isDefined } from '../common/utils'
import { generateDataSet } from '../components/web/generateDataSet'
import { useUniwindContext } from '../core/context'
Expand All @@ -10,6 +10,19 @@ import { classToColor, classToStyle, isClassProperty, isColorClassProperty, isSt

let warnedOnce = false

const useClassNames = (classNames: string) => {
const subscribe = useCallback(
(callback: () => void) => CSSListener.subscribeToClassName(classNames, callback),
[classNames],
)
const getSnapshot = useCallback(
() => CSSListener.getSnapshot(classNames),
[classNames],
)

useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}

export const withUniwind: WithUniwind = <
TComponent extends Component,
TOptions extends Record<keyof ComponentProps<TComponent>, OptionMapping>,
Expand Down Expand Up @@ -73,13 +86,7 @@ const withAutoUniwind = (Component: Component<AnyObject>) => (originalProps: Any
return acc
}, { generatedProps: {} as AnyObject, classNames: '' })

const [, rerender] = useReducer(() => ({}), {})

useLayoutEffect(() => {
const dispose = CSSListener.subscribeToClassName(classNames, rerender)

return dispose
}, [classNames])
useClassNames(classNames)

const dataSet = generateDataSet(props)

Expand Down Expand Up @@ -130,13 +137,7 @@ const withManualUniwind = (Component: Component<AnyObject>, options: Record<Prop
return acc
}, { generatedProps: {} as AnyObject, classNames: '' })

const [, rerender] = useReducer(() => ({}), {})

useLayoutEffect(() => {
const dispose = CSSListener.subscribeToClassName(classNames, rerender)

return dispose
}, [classNames])
useClassNames(classNames)

const dataSet = generateDataSet(props)

Expand Down
37 changes: 0 additions & 37 deletions packages/uniwind/tests/native/components/scoped-theme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,43 +217,6 @@ describe('ScopedTheme', () => {
expect(nestedLightInDark).toHaveBeenLastCalledWith('light')
})

test('hooks catch up after a suspended tree is revealed', () => {
const pending = { then() {} }
const seen = jest.fn()

const Suspender = (props: { freeze: boolean; children: React.ReactNode }) => {
if (props.freeze) {
throw pending
}

return props.children
}
const Probe = () => {
const background = useCSSVariable(['--color-background'])[0]
seen(useUniwind().theme, background)

return <View className="bg-background" testID="probe" />
}
const App = ({ freeze }: { freeze: boolean }) => (
<React.Suspense fallback={null}>
<Suspender freeze={freeze}>
<Probe />
</Suspender>
</React.Suspense>
)

const { getStylesFromId, rerender } = renderUniwind(<App freeze={false} />)
expect(seen).toHaveBeenLastCalledWith('light', '#ffffff')
expect(getStylesFromId('probe').backgroundColor).toBe('#ffffff')

rerender(<App freeze />)
act(() => Uniwind.setTheme('dark'))
rerender(<App freeze={false} />)

expect(seen).toHaveBeenLastCalledWith('dark', '#000000')
expect(getStylesFromId('probe').backgroundColor).toBe('#000000')
})

describe('updateCSSVariables', () => {
test('Component styles', () => {
const { getStylesFromId } = renderUniwind(
Expand Down
86 changes: 86 additions & 0 deletions packages/uniwind/tests/native/hoc/freeze.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { act } from '@testing-library/react-native'
import * as React from 'react'
import { ActivityIndicator as RNActivityIndicator, ActivityIndicatorProps } from 'react-native'
import { useUniwind } from '../../../src'
import ActivityIndicator from '../../../src/components/native/ActivityIndicator'
import View from '../../../src/components/native/View'
import { Uniwind } from '../../../src/core'
import { withUniwind } from '../../../src/hoc/withUniwind.native'
import { useCSSVariable } from '../../../src/hooks/useCSSVariable'
import { renderUniwind } from '../utils'

const Component: React.FC<ActivityIndicatorProps> = (props) => <RNActivityIndicator {...props} />
const AutoWithUniwind = withUniwind(Component)
const ManualWithUniwind = withUniwind(Component, {
style: { fromClassName: 'styleClassName' },
color: { fromClassName: 'colorClassName', styleProperty: 'accentColor' },
})

describe('freeze', () => {
afterEach(() => {
act(() => Uniwind.setTheme('light'))
})

test('external stores catch up after a suspended tree is revealed', () => {
const pending = { then() {} }
const themes = jest.fn()
const variables = jest.fn()

const Suspender = (props: { freeze: boolean; children: React.ReactNode }) => {
if (props.freeze) {
throw pending
}

return props.children
}
const ThemeProbe = () => {
themes(useUniwind().theme)

return null
}
const VariableProbe = () => {
variables(useCSSVariable('--color-background'))

return null
}
const App = ({ freeze }: { freeze: boolean }) => (
<React.Suspense fallback={null}>
<Suspender freeze={freeze}>
<ThemeProbe />
<VariableProbe />
<View className="bg-background" testID="regular-class" />
<ActivityIndicator colorClassName="accent-background" testID="regular-color" />
<AutoWithUniwind className="bg-background" colorClassName="accent-background" testID="auto" />
<ManualWithUniwind
styleClassName="bg-background"
colorClassName="accent-background"
testID="manual"
/>
</Suspender>
</React.Suspense>
)

const { getByTestId, getStylesFromId, rerender } = renderUniwind(<App freeze={false} />)
expect(themes).toHaveBeenLastCalledWith('light')
expect(variables).toHaveBeenLastCalledWith('#ffffff')
expect(getStylesFromId('regular-class').backgroundColor).toBe('#ffffff')
expect(getByTestId('regular-color').props.color).toBe('#ffffff')
expect(getStylesFromId('auto').backgroundColor).toBe('#ffffff')
expect(getByTestId('auto').props.color).toBe('#ffffff')
expect(getStylesFromId('manual').backgroundColor).toBe('#ffffff')
expect(getByTestId('manual').props.color).toBe('#ffffff')

rerender(<App freeze />)
act(() => Uniwind.setTheme('dark'))
rerender(<App freeze={false} />)

expect(themes).toHaveBeenLastCalledWith('dark')
expect(variables).toHaveBeenLastCalledWith('#000000')
expect(getStylesFromId('regular-class').backgroundColor).toBe('#000000')
expect(getByTestId('regular-color').props.color).toBe('#000000')
expect(getStylesFromId('auto').backgroundColor).toBe('#000000')
expect(getByTestId('auto').props.color).toBe('#000000')
expect(getStylesFromId('manual').backgroundColor).toBe('#000000')
expect(getByTestId('manual').props.color).toBe('#000000')
})
})
Loading