Skip to content
Draft
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 @@ -8,7 +8,7 @@
* @format
*/

import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView';
import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE';
import StyleSheet, {
type ColorValue,
type ViewStyleProp,
Expand Down
22 changes: 22 additions & 0 deletions packages/react-native/Libraries/Components/View/View.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@
*/

import type {HostInstance} from '../../../src/private/types/HostInstance';
import type {SafeAreaInsetsChangeEvent} from '../../Types/CoreEventTypes';
import type {ViewProps} from './ViewPropTypes';

import TextAncestorContext from '../../Text/TextAncestorContext';
import ViewNativeComponent from './ViewNativeComponent';
import * as React from 'react';
import {use} from 'react';

// Only development builds check for a view reporting its insets in a loop; the
// production branch keeps the handler as it is, and the module out of the bundle.
const warnOnRepeatedSafeAreaInsetsChanges: (
onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown,
) => (event: SafeAreaInsetsChangeEvent) => unknown = __DEV__
? require('../../../src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges')
.default
: onSafeAreaInsetsChange => onSafeAreaInsetsChange;

export type ViewInstance = HostInstance;

/**
Expand Down Expand Up @@ -115,6 +125,18 @@ component View(ref?: React.RefSetter<ViewInstance>, ...props: ViewProps) {
};
}

if (__DEV__) {
// Views are the only place the prop is used in practice, so the check for a
// view reporting its insets in a loop lives here rather than on every host
// component that inherits the prop.
const onSafeAreaInsetsChange =
resolvedProps.experimental_onSafeAreaInsetsChange;
if (onSafeAreaInsetsChange != null) {
resolvedProps.experimental_onSafeAreaInsetsChange =
warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange);
}
}

const actualView =
ref == null ? (
<ViewNativeComponent {...resolvedProps} />
Expand Down
27 changes: 27 additions & 0 deletions packages/react-native/Libraries/Components/View/ViewPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
LayoutRectangle,
MouseEvent,
PointerEvent,
SafeAreaInsetsChangeEvent,
} from '../../Types/CoreEventTypes';
import type {
AccessibilityActionEvent,
Expand Down Expand Up @@ -63,6 +64,32 @@ type DirectEventProps = Readonly<{
*/
onLayout?: ?(event: LayoutChangeEvent) => unknown,

/**
* Invoked when the part of this view that is covered by the system UI
* (status bar, navigation bar, home indicator, display cutouts, ...)
* changes, with:
*
* `{nativeEvent: {insets: {top, right, bottom, left}, frame: {x, y, width, height}}}`
*
* `insets` are relative to this view: an inset is only non-zero for the part
* of the view that actually overlaps the system UI. `frame` is the position
* of the view at the time of the event, relative to its enclosing view
* controller on iOS and to the window on Android; it does not trigger the
* event on its own, so it can be stale while the view moves without its
* insets changing.
*
* The event is dispatched synchronously, so the rendering it schedules is
* applied in the same frame the insets changed in.
*
* Setting this prop makes the view observe safe area changes; views without
* it are unaffected.
*
* @experimental
*/
experimental_onSafeAreaInsetsChange?: ?(
event: SafeAreaInsetsChangeEvent,
) => unknown,

/**
* When `accessible` is `true`, the system will invoke this function when the
* user performs the magic tap gesture.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 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
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import type {HostInstance} from 'react-native/src/private/types/HostInstance';

import * as Fantom from '@react-native/fantom';
import * as React from 'react';
import {createRef} from 'react';
import {View} from 'react-native';

const INSETS = {top: 44, right: 0, bottom: 34, left: 0};
const FRAME = {x: 0, y: 0, width: 390, height: 844};

describe('experimental_onSafeAreaInsetsChange', () => {
it('delivers the insets and the frame of the view', () => {
const root = Fantom.createRoot();
const nodeRef = createRef<HostInstance>();
const onSafeAreaInsetsChange = jest.fn();

Fantom.runTask(() => {
root.render(
<View
collapsable={false}
ref={nodeRef}
experimental_onSafeAreaInsetsChange={event => {
onSafeAreaInsetsChange(event.nativeEvent);
}}
/>,
);
});

Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
insets: INSETS,
frame: FRAME,
});

expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
const [event] = onSafeAreaInsetsChange.mock.lastCall;
expect(event.insets).toEqual(INSETS);
expect(event.frame).toEqual(FRAME);
});

it('is not delivered to views that did not opt in', () => {
const root = Fantom.createRoot();
const nodeRef = createRef<HostInstance>();

Fantom.runTask(() => {
root.render(<View collapsable={false} ref={nodeRef} />);
});

// The prop is what makes the view observe the safe area, so a view without
// it is never the target of the event.
expect(
root
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
.toJSX(),
).toEqual(<rn-view />);
});

it('prevents the view from being flattened', () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(
// A layout-only view would ordinarily be flattened away; observing the
// safe area requires a host view to observe with.
<View experimental_onSafeAreaInsetsChange={() => {}}>
<View collapsable={false} />
</View>,
);
});

expect(
root
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
.toJSX(),
).toEqual(
<rn-view experimental_onSafeAreaInsetsChange="true">
<rn-view />
</rn-view>,
);
});

it('is reflected in the props of the view when set', () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(
<View
collapsable={false}
experimental_onSafeAreaInsetsChange={() => {}}
/>,
);
});

expect(
root
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
.toJSX(),
).toEqual(<rn-view experimental_onSafeAreaInsetsChange="true" />);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* 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
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import type {HighResTimeStampMock} from '@react-native/fantom/src/HighResTimeStampMock';
import type {HostInstance} from 'react-native/src/private/types/HostInstance';

import * as Fantom from '@react-native/fantom';
import * as React from 'react';
import {createRef} from 'react';
import {View} from 'react-native';

const INSETS = {top: 44, right: 0, bottom: 34, left: 0};
const FRAME = {x: 0, y: 0, width: 390, height: 844};

function renderObservingView(): {current: HostInstance | null} {
const nodeRef = createRef<HostInstance>();
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<View ref={nodeRef} experimental_onSafeAreaInsetsChange={() => {}} />,
);
});
return nodeRef;
}

function dispatchInsetsChange(nodeRef: {current: HostInstance | null}) {
Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
insets: INSETS,
frame: FRAME,
});
}

describe('experimental_onSafeAreaInsetsChange warning', () => {
const originalConsoleWarn = console.warn;
let mockConsoleWarn: JestMockFn<ReadonlyArray<unknown>, void>;
let mockClock: ?HighResTimeStampMock;

beforeEach(() => {
mockConsoleWarn = jest.fn();
// $FlowFixMe[cannot-write]
console.warn = mockConsoleWarn;
mockClock = Fantom.installHighResTimeStampMock();
});

afterEach(() => {
// $FlowFixMe[cannot-write]
console.warn = originalConsoleWarn;
mockClock?.uninstall();
mockClock = null;
});

it('stays silent while the insets change at a plausible rate', () => {
const nodeRef = renderObservingView();

// A rotation, a keyboard, a split view: a handful of changes, spread out.
for (let i = 0; i < 20; i++) {
dispatchInsetsChange(nodeRef);
mockClock?.advanceTimeBy(200);
}

expect(mockConsoleWarn).not.toHaveBeenCalled();
});

it('warns once when a single view loops within the window', () => {
const nodeRef = renderObservingView();

for (let i = 0; i < 11; i++) {
dispatchInsetsChange(nodeRef);
mockClock?.advanceTimeBy(16);
}

expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
expect(mockConsoleWarn.mock.lastCall[0]).toContain(
'`experimental_onSafeAreaInsetsChange` fired more than 10 times in 1000ms',
);

// The loop keeps running; the warning does not.
for (let i = 0; i < 50; i++) {
dispatchInsetsChange(nodeRef);
mockClock?.advanceTimeBy(16);
}

expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
});

it('counts each view separately', () => {
const nodeRefA = renderObservingView();
const nodeRefB = renderObservingView();

for (let i = 0; i < 10; i++) {
dispatchInsetsChange(nodeRefA);
dispatchInsetsChange(nodeRefB);
mockClock?.advanceTimeBy(16);
}

expect(mockConsoleWarn).not.toHaveBeenCalled();

dispatchInsetsChange(nodeRefA);

expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
});

it('still delivers the event to the handler', () => {
const nodeRef = createRef<HostInstance>();
const onSafeAreaInsetsChange = jest.fn();
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<View
ref={nodeRef}
experimental_onSafeAreaInsetsChange={event => {
onSafeAreaInsetsChange(event.nativeEvent);
}}
/>,
);
});

dispatchInsetsChange(nodeRef);

expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
expect(onSafeAreaInsetsChange.mock.lastCall[0].insets).toEqual(INSETS);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @format
*/

import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView';
import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE';
import View from '../../Components/View/View';
import StyleSheet from '../../StyleSheet/StyleSheet';
import Text from '../../Text/Text';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import type {ViewProps} from '../../Components/View/ViewPropTypes';
import type {LogLevel} from '../Data/LogBoxLog';

import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView';
import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE';
import View from '../../Components/View/View';
import StyleSheet from '../../StyleSheet/StyleSheet';
import Text from '../../Text/Text';
Expand Down
Loading
Loading