Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

This file was deleted.

104 changes: 104 additions & 0 deletions packages/react-strict-dom/src/native/modules/StyleEnvironmentStore.js
Original file line number Diff line number Diff line change
@@ -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<Listener> = 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);
};
}
Original file line number Diff line number Diff line change
@@ -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 });
});
});

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
);
}
Loading