From 02b5eaabd619a58ee1aac7ec527c8a54d88d6dab Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Tue, 1 Sep 2026 08:18:28 +0200 Subject: [PATCH 1/2] fix: use useSyncExternalStore in withUniwind for better compatibility with react lifecycle --- packages/uniwind/src/core/listener.ts | 19 +++++++ packages/uniwind/src/core/web/cssListener.ts | 12 ++++ .../uniwind/src/hoc/withUniwind.native.tsx | 44 +++++++------- packages/uniwind/src/hoc/withUniwind.tsx | 31 +++++----- .../native/components/scoped-theme.test.tsx | 37 ------------ .../uniwind/tests/native/hoc/freeze.test.tsx | 54 ++++++++++++++++++ .../uniwind/tests/web/hoc/freeze.test.tsx | 57 +++++++++++++++++++ 7 files changed, 179 insertions(+), 75 deletions(-) create mode 100644 packages/uniwind/tests/native/hoc/freeze.test.tsx create mode 100644 packages/uniwind/tests/web/hoc/freeze.test.tsx diff --git a/packages/uniwind/src/core/listener.ts b/packages/uniwind/src/core/listener.ts index cd9794e6..9734047f 100644 --- a/packages/uniwind/src/core/listener.ts +++ b/packages/uniwind/src/core/listener.ts @@ -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>(), @@ -17,13 +28,21 @@ class UniwindListenerBuilder { [StyleDependency.Variables]: new Set<() => void>(), } + getSnapshot = (dependencies: Array) => + dependencies.reduce( + (snapshot, dependency) => snapshot + this.revisions[dependency], + 0, + ) + notify(dependencies: Array) { 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()) }) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 68bde329..d5801c87 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -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 diff --git a/packages/uniwind/src/hoc/withUniwind.native.tsx b/packages/uniwind/src/hoc/withUniwind.native.tsx index 8f613b89..8453d3c3 100644 --- a/packages/uniwind/src/hoc/withUniwind.native.tsx +++ b/packages/uniwind/src/hoc/withUniwind.native.tsx @@ -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' @@ -11,6 +11,24 @@ import { classToColor, classToStyle, isClassProperty, isColorClassProperty, isSt let warnedOnce = false +const useDependencies = (dependencies: Array) => { + 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, OptionMapping>, @@ -67,17 +85,7 @@ const withAutoUniwind = (Component: Component) => (props: AnyObject) return acc }, { generatedProps: {} as AnyObject, dependencies: [] as Array }) - 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 ( , options: Record }) - 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 ( { + 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, OptionMapping>, @@ -73,13 +86,7 @@ const withAutoUniwind = (Component: Component) => (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) @@ -130,13 +137,7 @@ const withManualUniwind = (Component: Component, options: Record ({}), {}) - - useLayoutEffect(() => { - const dispose = CSSListener.subscribeToClassName(classNames, rerender) - - return dispose - }, [classNames]) + useClassNames(classNames) const dataSet = generateDataSet(props) diff --git a/packages/uniwind/tests/native/components/scoped-theme.test.tsx b/packages/uniwind/tests/native/components/scoped-theme.test.tsx index 75751976..be0b8048 100644 --- a/packages/uniwind/tests/native/components/scoped-theme.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-theme.test.tsx @@ -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 - } - const App = ({ freeze }: { freeze: boolean }) => ( - - - - - - ) - - const { getStylesFromId, rerender } = renderUniwind() - expect(seen).toHaveBeenLastCalledWith('light', '#ffffff') - expect(getStylesFromId('probe').backgroundColor).toBe('#ffffff') - - rerender() - act(() => Uniwind.setTheme('dark')) - rerender() - - expect(seen).toHaveBeenLastCalledWith('dark', '#000000') - expect(getStylesFromId('probe').backgroundColor).toBe('#000000') - }) - describe('updateCSSVariables', () => { test('Component styles', () => { const { getStylesFromId } = renderUniwind( diff --git a/packages/uniwind/tests/native/hoc/freeze.test.tsx b/packages/uniwind/tests/native/hoc/freeze.test.tsx new file mode 100644 index 00000000..492c2412 --- /dev/null +++ b/packages/uniwind/tests/native/hoc/freeze.test.tsx @@ -0,0 +1,54 @@ +import { act } from '@testing-library/react-native' +import * as React from 'react' +import { ActivityIndicator, ActivityIndicatorProps } from 'react-native' +import { useUniwind } from '../../../src' +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 = (props) => +const WithUniwind = withUniwind(Component) + +describe('withUniwind freeze', () => { + afterEach(() => { + act(() => Uniwind.setTheme('light')) + }) + + test('catches 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 + } + const App = ({ freeze }: { freeze: boolean }) => ( + + + + + + ) + + const { getStylesFromId, rerender } = renderUniwind() + expect(seen).toHaveBeenLastCalledWith('light', '#ffffff') + expect(getStylesFromId('probe').backgroundColor).toBe('#ffffff') + + rerender() + act(() => Uniwind.setTheme('dark')) + rerender() + + expect(seen).toHaveBeenLastCalledWith('dark', '#000000') + expect(getStylesFromId('probe').backgroundColor).toBe('#000000') + }) +}) diff --git a/packages/uniwind/tests/web/hoc/freeze.test.tsx b/packages/uniwind/tests/web/hoc/freeze.test.tsx new file mode 100644 index 00000000..4e628747 --- /dev/null +++ b/packages/uniwind/tests/web/hoc/freeze.test.tsx @@ -0,0 +1,57 @@ +import { act, render } from '@testing-library/react' +import * as React from 'react' +import { ActivityIndicator, ActivityIndicatorProps } from 'react-native' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { Uniwind } from '../../../src/core' +import { withUniwind } from '../../../src/hoc/withUniwind' + +const state = vi.hoisted(() => ({ color: '#ffffff' })) + +vi.mock('../../../src/core/web', async importOriginal => ({ + ...await importOriginal(), + getWebStyles: () => ({ accentColor: state.color }), +})) + +describe('withUniwind freeze', () => { + afterEach(() => { + state.color = '#ffffff' + act(() => Uniwind.setTheme('light')) + }) + + test('catches up after a suspended tree is revealed', () => { + const pending = { then() {} } + const seen = vi.fn() + const Component: React.FC = (props) => { + seen(props.color) + + return + } + const WithUniwind = withUniwind(Component) + const Suspender = (props: { freeze: boolean; children: React.ReactNode }) => { + if (props.freeze) { + throw pending + } + + return props.children + } + const App = ({ freeze }: { freeze: boolean }) => ( + + + + + + ) + + const { rerender } = render() + expect(seen).toHaveBeenLastCalledWith('#ffffff') + + rerender() + act(() => { + state.color = '#000000' + Uniwind.setTheme('dark') + }) + rerender() + + expect(seen).toHaveBeenLastCalledWith('#000000') + }) +}) From b06118990a77cbe6b62531ae70c775c23aed19fb Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Tue, 1 Sep 2026 08:35:41 +0200 Subject: [PATCH 2/2] test: extend freeze tests --- .../uniwind/tests/native/hoc/freeze.test.tsx | 64 ++++++++++++----- .../uniwind/tests/web/hoc/freeze.test.tsx | 72 +++++++++++++++---- 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/packages/uniwind/tests/native/hoc/freeze.test.tsx b/packages/uniwind/tests/native/hoc/freeze.test.tsx index 492c2412..f969745e 100644 --- a/packages/uniwind/tests/native/hoc/freeze.test.tsx +++ b/packages/uniwind/tests/native/hoc/freeze.test.tsx @@ -1,23 +1,30 @@ import { act } from '@testing-library/react-native' import * as React from 'react' -import { ActivityIndicator, ActivityIndicatorProps } from 'react-native' +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 = (props) => -const WithUniwind = withUniwind(Component) +const Component: React.FC = (props) => +const AutoWithUniwind = withUniwind(Component) +const ManualWithUniwind = withUniwind(Component, { + style: { fromClassName: 'styleClassName' }, + color: { fromClassName: 'colorClassName', styleProperty: 'accentColor' }, +}) -describe('withUniwind freeze', () => { +describe('freeze', () => { afterEach(() => { act(() => Uniwind.setTheme('light')) }) - test('catches up after a suspended tree is revealed', () => { + test('external stores catch up after a suspended tree is revealed', () => { const pending = { then() {} } - const seen = jest.fn() + const themes = jest.fn() + const variables = jest.fn() const Suspender = (props: { freeze: boolean; children: React.ReactNode }) => { if (props.freeze) { @@ -26,29 +33,54 @@ describe('withUniwind freeze', () => { return props.children } - const Probe = () => { - const background = useCSSVariable(['--color-background'])[0] - seen(useUniwind().theme, background) + const ThemeProbe = () => { + themes(useUniwind().theme) + + return null + } + const VariableProbe = () => { + variables(useCSSVariable('--color-background')) - return + return null } const App = ({ freeze }: { freeze: boolean }) => ( - + + + + + + ) - const { getStylesFromId, rerender } = renderUniwind() - expect(seen).toHaveBeenLastCalledWith('light', '#ffffff') - expect(getStylesFromId('probe').backgroundColor).toBe('#ffffff') + const { getByTestId, getStylesFromId, rerender } = renderUniwind() + 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() act(() => Uniwind.setTheme('dark')) rerender() - expect(seen).toHaveBeenLastCalledWith('dark', '#000000') - expect(getStylesFromId('probe').backgroundColor).toBe('#000000') + 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') }) }) diff --git a/packages/uniwind/tests/web/hoc/freeze.test.tsx b/packages/uniwind/tests/web/hoc/freeze.test.tsx index 4e628747..d378bdcb 100644 --- a/packages/uniwind/tests/web/hoc/freeze.test.tsx +++ b/packages/uniwind/tests/web/hoc/freeze.test.tsx @@ -1,32 +1,49 @@ import { act, render } from '@testing-library/react' import * as React from 'react' -import { ActivityIndicator, ActivityIndicatorProps } from 'react-native' +import { ActivityIndicator as RNActivityIndicator, ActivityIndicatorProps } from 'react-native' import { afterEach, describe, expect, test, vi } from 'vitest' +import { useUniwind } from '../../../src' +import ActivityIndicator from '../../../src/components/web/ActivityIndicator' +import View from '../../../src/components/web/View' import { Uniwind } from '../../../src/core' import { withUniwind } from '../../../src/hoc/withUniwind' +import { useCSSVariable } from '../../../src/hooks/useCSSVariable' const state = vi.hoisted(() => ({ color: '#ffffff' })) vi.mock('../../../src/core/web', async importOriginal => ({ ...await importOriginal(), - getWebStyles: () => ({ accentColor: state.color }), + getWebStyles: () => ({ accentColor: state.color, backgroundColor: state.color }), + getWebVariable: () => state.color, })) -describe('withUniwind freeze', () => { +describe('freeze', () => { afterEach(() => { state.color = '#ffffff' act(() => Uniwind.setTheme('light')) }) - test('catches up after a suspended tree is revealed', () => { + test('external stores catch up after a suspended tree is revealed', () => { const pending = { then() {} } - const seen = vi.fn() - const Component: React.FC = (props) => { - seen(props.color) + const themes = vi.fn() + const variables = vi.fn() + const auto = vi.fn() + const manual = vi.fn() + const AutoComponent: React.FC = (props) => { + auto(props) - return + return } - const WithUniwind = withUniwind(Component) + const ManualComponent: React.FC = (props) => { + manual(props) + + return + } + const AutoWithUniwind = withUniwind(AutoComponent) + const ManualWithUniwind = withUniwind(ManualComponent, { + style: { fromClassName: 'styleClassName' }, + color: { fromClassName: 'colorClassName', styleProperty: 'accentColor' }, + }) const Suspender = (props: { freeze: boolean; children: React.ReactNode }) => { if (props.freeze) { throw pending @@ -34,16 +51,40 @@ describe('withUniwind freeze', () => { return props.children } + const ThemeProbe = () => { + themes(useUniwind().theme) + + return null + } + const VariableProbe = () => { + variables(useCSSVariable('--color-background')) + + return null + } const App = ({ freeze }: { freeze: boolean }) => ( - + + + + + + ) - const { rerender } = render() - expect(seen).toHaveBeenLastCalledWith('#ffffff') + const { getByTestId, rerender } = render() + expect(themes).toHaveBeenLastCalledWith('light') + expect(variables).toHaveBeenLastCalledWith('#ffffff') + expect(getByTestId('regular-class')).toHaveClass('bg-background') + expect(getByTestId('regular-color').querySelector('circle:last-child')?.style.stroke).toBe('rgb(255, 255, 255)') + expect(auto).toHaveBeenLastCalledWith(expect.objectContaining({ color: '#ffffff' })) + expect(manual).toHaveBeenLastCalledWith(expect.objectContaining({ color: '#ffffff' })) rerender() act(() => { @@ -52,6 +93,11 @@ describe('withUniwind freeze', () => { }) rerender() - expect(seen).toHaveBeenLastCalledWith('#000000') + expect(themes).toHaveBeenLastCalledWith('dark') + expect(variables).toHaveBeenLastCalledWith('#000000') + expect(getByTestId('regular-class')).toHaveClass('bg-background') + expect(getByTestId('regular-color').querySelector('circle:last-child')?.style.stroke).toBe('rgb(0, 0, 0)') + expect(auto).toHaveBeenLastCalledWith(expect.objectContaining({ color: '#000000' })) + expect(manual).toHaveBeenLastCalledWith(expect.objectContaining({ color: '#000000' })) }) })