diff --git a/packages/react-strict-dom/src/native/modules/ContextCustomProperties.js b/packages/react-strict-dom/src/native/modules/ContextCustomProperties.js index 5cf5049b..34672260 100644 --- a/packages/react-strict-dom/src/native/modules/ContextCustomProperties.js +++ b/packages/react-strict-dom/src/native/modules/ContextCustomProperties.js @@ -29,15 +29,13 @@ export function useCustomProperties( customPropertiesFromThemes: ?CustomProperties ): CustomProperties { const inheritedCustomProperties = React.useContext(ContextCustomProperties); - return React.useMemo(() => { - if (customPropertiesFromThemes == null) { - return inheritedCustomProperties; - } - // $FlowExpectedError[unsafe-object-assign] - return Object.assign( - {}, - inheritedCustomProperties, - customPropertiesFromThemes - ); - }, [inheritedCustomProperties, customPropertiesFromThemes]); + if (customPropertiesFromThemes == null) { + return inheritedCustomProperties; + } + // $FlowExpectedError[unsafe-object-assign] + return Object.assign( + {}, + inheritedCustomProperties, + customPropertiesFromThemes + ); } diff --git a/packages/react-strict-dom/src/native/modules/PrefersReducedMotionStore.js b/packages/react-strict-dom/src/native/modules/PrefersReducedMotionStore.js deleted file mode 100644 index 36b7c8ba..00000000 --- a/packages/react-strict-dom/src/native/modules/PrefersReducedMotionStore.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - */ - -import type { EventSubscription } from 'react-native/Libraries/vendor/emitter/EventEmitter'; - -import * as ReactNative from '../react-native'; - -type Listener = () => void; - -const listeners: Set = new Set(); - -let prefersReducedMotion = false; -let isInitialized = false; -let reduceMotionChangedSubscription: ?EventSubscription = null; - -function setPrefersReducedMotion(nextValue: boolean) { - if (prefersReducedMotion !== nextValue) { - prefersReducedMotion = nextValue; - - Array.from(listeners).forEach((listener) => { - listener(); - }); - } -} - -function ensureInitialized() { - if (isInitialized) { - return; - } - - isInitialized = true; - - if (reduceMotionChangedSubscription == null) { - reduceMotionChangedSubscription = - ReactNative.AccessibilityInfo.addEventListener( - 'reduceMotionChanged', - (isReduceMotionEnabled) => { - setPrefersReducedMotion(isReduceMotionEnabled); - } - ); - } - - ReactNative.AccessibilityInfo.isReduceMotionEnabled().then( - (isReduceMotionEnabled) => { - setPrefersReducedMotion(isReduceMotionEnabled); - }, - () => { - // Silently ignore if the native module is not available (e.g., on VR) - } - ); -} - -export function getPrefersReducedMotionSnapshot(): boolean { - return prefersReducedMotion; -} - -export function subscribeToPrefersReducedMotion( - listener: Listener -): () => void { - listeners.add(listener); - ensureInitialized(); - - return () => { - listeners.delete(listener); - }; -} diff --git a/packages/react-strict-dom/src/native/modules/StyleEnvironmentStore.js b/packages/react-strict-dom/src/native/modules/StyleEnvironmentStore.js new file mode 100644 index 00000000..bf03c185 --- /dev/null +++ b/packages/react-strict-dom/src/native/modules/StyleEnvironmentStore.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +import * as ReactNative from '../react-native'; + +type Listener = () => void; + +export type StyleEnvironment = { + +fontScale: number, + +height: number, + +width: number, + +colorScheme: 'light' | 'dark', + +prefersReducedMotion: boolean +}; + +const listeners: Set = new Set(); + +let snapshot: ?StyleEnvironment = null; +let isInitialized = false; +let prefersReducedMotion = false; + +function computeSnapshot(): StyleEnvironment { + const { fontScale, height, width } = ReactNative.Dimensions.get('window'); + const colorScheme = ReactNative.Appearance.getColorScheme() ?? 'light'; + return { + fontScale, + height, + width, + colorScheme: colorScheme === 'dark' ? 'dark' : 'light', + prefersReducedMotion + }; +} + +function refreshSnapshot(): boolean { + const next = computeSnapshot(); + const prev = snapshot; + if ( + prev == null || + prev.fontScale !== next.fontScale || + prev.height !== next.height || + prev.width !== next.width || + prev.colorScheme !== next.colorScheme || + prev.prefersReducedMotion !== next.prefersReducedMotion + ) { + snapshot = next; + return true; + } + return false; +} + +function updateSnapshot() { + if (refreshSnapshot()) { + Array.from(listeners).forEach((listener) => { + listener(); + }); + } +} + +function ensureInitialized() { + if (isInitialized) { + return; + } + isInitialized = true; + ReactNative.Dimensions.addEventListener('change', updateSnapshot); + ReactNative.Appearance.addChangeListener(updateSnapshot); + ReactNative.AccessibilityInfo.addEventListener( + 'reduceMotionChanged', + (isReduceMotionEnabled) => { + prefersReducedMotion = isReduceMotionEnabled; + updateSnapshot(); + } + ); + ReactNative.AccessibilityInfo.isReduceMotionEnabled().then( + (isReduceMotionEnabled) => { + prefersReducedMotion = isReduceMotionEnabled; + updateSnapshot(); + }, + () => { + // Silently ignore if the native module is not available (e.g., on VR) + } + ); +} + +export function getStyleEnvironmentSnapshot(): StyleEnvironment { + if (snapshot == null) { + snapshot = computeSnapshot(); + } + return snapshot; +} + +export function subscribeToStyleEnvironment(listener: Listener): () => void { + listeners.add(listener); + ensureInitialized(); + refreshSnapshot(); + return () => { + listeners.delete(listener); + }; +} diff --git a/packages/react-strict-dom/src/native/modules/__tests__/StyleEnvironmentStore-test.js b/packages/react-strict-dom/src/native/modules/__tests__/StyleEnvironmentStore-test.js new file mode 100644 index 00000000..c7d0c8b6 --- /dev/null +++ b/packages/react-strict-dom/src/native/modules/__tests__/StyleEnvironmentStore-test.js @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +let mockAppearanceListener; +let mockColorScheme; +let mockDimensions; +let mockDimensionsListener; +let mockPrefersReducedMotion; +let mockReducedMotionListener; + +jest.mock('../../react-native', () => ({ + AccessibilityInfo: { + addEventListener: jest.fn((event, listener) => { + mockReducedMotionListener = listener; + return { remove: jest.fn() }; + }), + isReduceMotionEnabled: jest.fn(() => + Promise.resolve(mockPrefersReducedMotion) + ) + }, + Appearance: { + addChangeListener: jest.fn((listener) => { + mockAppearanceListener = listener; + return { remove: jest.fn() }; + }), + getColorScheme: jest.fn(() => mockColorScheme) + }, + Dimensions: { + addEventListener: jest.fn((event, listener) => { + mockDimensionsListener = listener; + return { remove: jest.fn() }; + }), + get: jest.fn(() => mockDimensions) + } +})); + +describe('StyleEnvironmentStore', () => { + let ReactNative; + let store; + + beforeEach(() => { + jest.resetModules(); + mockAppearanceListener = null; + mockColorScheme = 'light'; + mockDimensions = { fontScale: 1, height: 1000, width: 2000 }; + mockDimensionsListener = null; + mockPrefersReducedMotion = false; + mockReducedMotionListener = null; + ReactNative = require('../../react-native'); + store = require('../StyleEnvironmentStore'); + }); + + test('returns a stable snapshot', () => { + const first = store.getStyleEnvironmentSnapshot(); + const second = store.getStyleEnvironmentSnapshot(); + + expect(second).toBe(first); + expect(ReactNative.Dimensions.get).toHaveBeenCalledTimes(1); + expect(ReactNative.Appearance.getColorScheme).toHaveBeenCalledTimes(1); + }); + + test('subscribes to each environment source once', () => { + const unsubscribeFirst = store.subscribeToStyleEnvironment(jest.fn()); + const unsubscribeSecond = store.subscribeToStyleEnvironment(jest.fn()); + + expect(ReactNative.Dimensions.addEventListener).toHaveBeenCalledTimes(1); + expect(ReactNative.Appearance.addChangeListener).toHaveBeenCalledTimes(1); + expect(ReactNative.AccessibilityInfo.addEventListener).toHaveBeenCalledTimes( + 1 + ); + expect( + ReactNative.AccessibilityInfo.isReduceMotionEnabled + ).toHaveBeenCalledTimes(1); + + unsubscribeFirst(); + unsubscribeSecond(); + }); + + test('updates dimensions only when values change', () => { + const listener = jest.fn(); + store.subscribeToStyleEnvironment(listener); + const first = store.getStyleEnvironmentSnapshot(); + + mockDimensionsListener(); + expect(listener).not.toHaveBeenCalled(); + expect(store.getStyleEnvironmentSnapshot()).toBe(first); + + mockDimensions = { fontScale: 2, height: 500, width: 800 }; + mockDimensionsListener(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(store.getStyleEnvironmentSnapshot()).toEqual({ + colorScheme: 'light', + fontScale: 2, + height: 500, + prefersReducedMotion: false, + width: 800 + }); + }); + + test('normalizes and updates the color scheme', () => { + const listener = jest.fn(); + store.subscribeToStyleEnvironment(listener); + + mockColorScheme = 'dark'; + mockAppearanceListener(); + expect(store.getStyleEnvironmentSnapshot().colorScheme).toBe('dark'); + + mockColorScheme = null; + mockAppearanceListener(); + expect(store.getStyleEnvironmentSnapshot().colorScheme).toBe('light'); + expect(listener).toHaveBeenCalledTimes(2); + }); + + test('updates reduced motion', async () => { + const listener = jest.fn(); + store.subscribeToStyleEnvironment(listener); + await Promise.resolve(); + + mockReducedMotionListener(true); + + expect(listener).toHaveBeenCalledTimes(1); + expect(store.getStyleEnvironmentSnapshot().prefersReducedMotion).toBe(true); + }); + + test('refreshes after subscribing', () => { + const first = store.getStyleEnvironmentSnapshot(); + mockDimensions = { fontScale: 1, height: 500, width: 800 }; + + store.subscribeToStyleEnvironment(jest.fn()); + + const second = store.getStyleEnvironmentSnapshot(); + expect(second).not.toBe(first); + expect(second).toMatchObject({ height: 500, width: 800 }); + }); +}); diff --git a/packages/react-strict-dom/src/native/modules/usePrefersReducedMotion.js b/packages/react-strict-dom/src/native/modules/usePrefersReducedMotion.js deleted file mode 100644 index e675f8b1..00000000 --- a/packages/react-strict-dom/src/native/modules/usePrefersReducedMotion.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - */ - -import * as React from 'react'; - -import { - getPrefersReducedMotionSnapshot, - subscribeToPrefersReducedMotion -} from './PrefersReducedMotionStore'; - -export function usePrefersReducedMotion(): boolean { - return React.useSyncExternalStore( - subscribeToPrefersReducedMotion, - getPrefersReducedMotionSnapshot, - getPrefersReducedMotionSnapshot - ); -} diff --git a/packages/react-strict-dom/src/native/modules/useStyleEnvironment.js b/packages/react-strict-dom/src/native/modules/useStyleEnvironment.js new file mode 100644 index 00000000..70755e5b --- /dev/null +++ b/packages/react-strict-dom/src/native/modules/useStyleEnvironment.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +import type { StyleEnvironment } from './StyleEnvironmentStore'; + +import * as React from 'react'; + +import { + getStyleEnvironmentSnapshot, + subscribeToStyleEnvironment +} from './StyleEnvironmentStore'; + +export function useStyleEnvironment(): StyleEnvironment { + return React.useSyncExternalStore( + subscribeToStyleEnvironment, + getStyleEnvironmentSnapshot, + getStyleEnvironmentSnapshot + ); +} diff --git a/packages/react-strict-dom/src/native/modules/useStyleProps.js b/packages/react-strict-dom/src/native/modules/useStyleProps.js index 95bb42e9..fcd74039 100644 --- a/packages/react-strict-dom/src/native/modules/useStyleProps.js +++ b/packages/react-strict-dom/src/native/modules/useStyleProps.js @@ -15,11 +15,10 @@ import type { } from '../../types/renderer.native'; import * as css from '../css'; -import * as ReactNative from '../react-native'; import { useInheritedStyles } from './ContextInheritedStyles'; -import { usePrefersReducedMotion } from './usePrefersReducedMotion'; import { usePseudoStates } from './usePseudoStates'; +import { useStyleEnvironment } from './useStyleEnvironment'; import { useStyleTransition } from './useStyleTransition'; import { useViewportScale } from './ContextViewportScale'; @@ -109,8 +108,8 @@ export function useStyleProps( writingDirection: dir } = options; - const { fontScale, height, width } = ReactNative.useWindowDimensions(); - const colorScheme = ReactNative.useColorScheme(); + const { fontScale, height, width, colorScheme, prefersReducedMotion } = + useStyleEnvironment(); const { scale: viewportScale } = useViewportScale(); // These values are already computed @@ -135,13 +134,12 @@ export function useStyleProps( } = useInheritedStyles(); const { active, focus, hover, handlers } = usePseudoStates(flatStyle); - const prefersReducedMotion = usePrefersReducedMotion(); // Get the computed style props const styleProps = css.props.call( { active, - colorScheme: colorScheme === 'unspecified' ? 'light' : colorScheme, + colorScheme, customProperties: customProperties ?? emptyObject, focus, fontScale, diff --git a/packages/react-strict-dom/src/native/react-native/index.js b/packages/react-strict-dom/src/native/react-native/index.js index 1fe99bb0..8b290376 100644 --- a/packages/react-strict-dom/src/native/react-native/index.js +++ b/packages/react-strict-dom/src/native/react-native/index.js @@ -10,6 +10,8 @@ export { AccessibilityInfo, Animated, + Appearance, + Dimensions, Easing, Image, Platform, @@ -20,4 +22,4 @@ export { export { LayoutConformance } from './LayoutConformance'; export { TextAncestorContext } from './TextAncestorContext'; export { ViewNativeComponent } from './ViewNativeComponent'; -export { useColorScheme, useWindowDimensions } from 'react-native'; +export { useWindowDimensions } from 'react-native'; diff --git a/packages/react-strict-dom/tests/__mocks__/react-native/index.js b/packages/react-strict-dom/tests/__mocks__/react-native/index.js index 01aadcd7..1bee1a11 100644 --- a/packages/react-strict-dom/tests/__mocks__/react-native/index.js +++ b/packages/react-strict-dom/tests/__mocks__/react-native/index.js @@ -49,6 +49,18 @@ export const Animated = { }) }; +export const Appearance = { + getColorScheme: jest.fn().mockReturnValue('light'), + addChangeListener: jest.fn().mockReturnValue({ remove: jest.fn() }) +}; + +export const Dimensions = { + get: jest + .fn() + .mockReturnValue({ fontScale: 1, height: 1000, scale: 1, width: 2000 }), + addEventListener: jest.fn().mockReturnValue({ remove: jest.fn() }) +}; + export const Easing = { linear: jest.fn(), ease: jest.fn(), @@ -122,8 +134,6 @@ export const experimental_LayoutConformance = 'LayoutConformance'; export const unstable_TextAncestorContext = 'TextAncestorContext'; -export const useColorScheme = jest.fn().mockReturnValue('light'); - export const useWindowDimensions = jest .fn() .mockReturnValue({ fontScale: 1, height: 1000, scale: 1, width: 2000 }); diff --git a/packages/react-strict-dom/tests/css/css-create-queries-test.native.js b/packages/react-strict-dom/tests/css/css-create-queries-test.native.js index 90721575..5e388aa5 100644 --- a/packages/react-strict-dom/tests/css/css-create-queries-test.native.js +++ b/packages/react-strict-dom/tests/css/css-create-queries-test.native.js @@ -13,11 +13,11 @@ describe('css.create(): @media', () => { const ReactNative = require('../../src/native/react-native'); beforeEach(() => { - ReactNative.useWindowDimensions.mockReturnValue({ + ReactNative.Dimensions.get.mockReturnValue({ height: 1000, width: 2000 }); - ReactNative.useColorScheme.mockReturnValue('light'); + ReactNative.Appearance.getColorScheme.mockReturnValue('light'); // avoid console messages for these tests jest.spyOn(console, 'error'); @@ -27,11 +27,11 @@ describe('css.create(): @media', () => { }); afterEach(() => { - ReactNative.useWindowDimensions.mockReturnValue({ + ReactNative.Dimensions.get.mockReturnValue({ height: 1000, width: 2000 }); - ReactNative.useColorScheme.mockReturnValue('light'); + ReactNative.Appearance.getColorScheme.mockReturnValue('light'); console.error.mockRestore(); console.warn.mockRestore(); @@ -90,7 +90,7 @@ describe('css.create(): @media', () => { describe('query matches', () => { test('(max-height: 400px)', () => { - ReactNative.useWindowDimensions.mockReturnValue({ height: 200 }); + ReactNative.Dimensions.get.mockReturnValue({ height: 200 }); let root; act(() => { root = create(); @@ -107,7 +107,7 @@ describe('css.create(): @media', () => { }); test('(max-width: 400px)', () => { - ReactNative.useWindowDimensions.mockReturnValue({ width: 200 }); + ReactNative.Dimensions.get.mockReturnValue({ width: 200 }); let root; act(() => { root = create(); @@ -124,7 +124,7 @@ describe('css.create(): @media', () => { }); test('(prefers-color-scheme: light)', () => { - ReactNative.useColorScheme.mockReturnValue('light'); + ReactNative.Appearance.getColorScheme.mockReturnValue('light'); let root; act(() => { root = create(); @@ -133,7 +133,7 @@ describe('css.create(): @media', () => { }); test('(prefers-color-scheme: dark)', () => { - ReactNative.useColorScheme.mockReturnValue('dark'); + ReactNative.Appearance.getColorScheme.mockReturnValue('dark'); let root; act(() => { root = create(); @@ -144,7 +144,7 @@ describe('css.create(): @media', () => { describe('query does not match', () => { test('(max-height: 400px)', () => { - ReactNative.useWindowDimensions.mockReturnValue({ height: 500 }); + ReactNative.Dimensions.get.mockReturnValue({ height: 500 }); let root; act(() => { root = create(); @@ -153,7 +153,7 @@ describe('css.create(): @media', () => { }); test('(min-height: 400px)', () => { - ReactNative.useWindowDimensions.mockReturnValue({ height: 200 }); + ReactNative.Dimensions.get.mockReturnValue({ height: 200 }); let root; act(() => { root = create(); @@ -162,7 +162,7 @@ describe('css.create(): @media', () => { }); test('(max-width: 400px)', () => { - ReactNative.useWindowDimensions.mockReturnValue({ width: 500 }); + ReactNative.Dimensions.get.mockReturnValue({ width: 500 }); let root; act(() => { root = create(); @@ -171,7 +171,7 @@ describe('css.create(): @media', () => { }); test('(min-width: 400px)', () => { - ReactNative.useWindowDimensions.mockReturnValue({ width: 200 }); + ReactNative.Dimensions.get.mockReturnValue({ width: 200 }); let root; act(() => { root = create(); @@ -180,7 +180,7 @@ describe('css.create(): @media', () => { }); test('(prefers-color-scheme: light)', () => { - ReactNative.useColorScheme.mockReturnValue('dark'); + ReactNative.Appearance.getColorScheme.mockReturnValue('dark'); let root; act(() => { root = create(); @@ -189,7 +189,7 @@ describe('css.create(): @media', () => { }); test('(prefers-color-scheme: dark)', () => { - ReactNative.useColorScheme.mockReturnValue('light'); + ReactNative.Appearance.getColorScheme.mockReturnValue('light'); let root; act(() => { root = create(); diff --git a/packages/react-strict-dom/tests/css/css-create-test.native.js b/packages/react-strict-dom/tests/css/css-create-test.native.js index 2067238f..13af8c88 100644 --- a/packages/react-strict-dom/tests/css/css-create-test.native.js +++ b/packages/react-strict-dom/tests/css/css-create-test.native.js @@ -500,10 +500,10 @@ describe('css.create()', () => { describe('fontSize scaling', () => { const ReactNative = require('../../src/native/react-native'); beforeEach(() => { - ReactNative.useWindowDimensions.mockReturnValue({ fontScale: 2 }); + ReactNative.Dimensions.get.mockReturnValue({ fontScale: 2 }); }); afterEach(() => { - ReactNative.useWindowDimensions.mockReturnValue({ fontScale: 1 }); + ReactNative.Dimensions.get.mockReturnValue({ fontScale: 1 }); }); test('fontScale:2', () => { diff --git a/packages/react-strict-dom/tests/css/css-themes-test.native.js b/packages/react-strict-dom/tests/css/css-themes-test.native.js index 9186d0d7..70d715bb 100644 --- a/packages/react-strict-dom/tests/css/css-themes-test.native.js +++ b/packages/react-strict-dom/tests/css/css-themes-test.native.js @@ -18,7 +18,7 @@ describe('css.* themes', () => { console.error.mockImplementation(() => {}); console.warn.mockImplementation(() => {}); - ReactNative.useColorScheme.mockReturnValue('light'); + ReactNative.Appearance.getColorScheme.mockReturnValue('light'); }); afterEach(() => { @@ -68,7 +68,7 @@ describe('css.* themes', () => { expect(root.toJSON().props.style.color).toBe('blue'); // dark theme - ReactNative.useColorScheme.mockReturnValue('dark'); + ReactNative.Appearance.getColorScheme.mockReturnValue('dark'); act(() => { root = create(); });