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
49 changes: 46 additions & 3 deletions src/NativeSafeAreaProvider.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as React from 'react';
import { View } from 'react-native';
import type { NativeSafeAreaProviderProps } from './SafeArea.types';
import type { Metrics, NativeSafeAreaProviderProps } from './SafeArea.types';

const CSSTransitions: Record<string, string> = {
WebkitTransition: 'webkitTransitionEnd',
Expand Down Expand Up @@ -50,6 +50,7 @@ export function NativeSafeAreaProvider({

const element = createContextElement();
document.body.appendChild(element);
let reportedMetrics: Metrics | null = null;
const onEnd = () => {
const { paddingTop, paddingBottom, paddingLeft, paddingRight } =
window.getComputedStyle(element);
Expand Down Expand Up @@ -94,11 +95,19 @@ export function NativeSafeAreaProvider({
};
}

// Like the native providers, report only metrics that changed.
if (
reportedMetrics != null &&
areMetricsEqual(reportedMetrics, { insets, frame })
) {
return;
}
reportedMetrics = { insets, frame };
// @ts-ignore: missing properties
onInsetsChange({ nativeEvent: { insets, frame } });
};
element.addEventListener(getSupportedTransitionEvent(), onEnd);
window.addEventListener('resize', onEnd);
const stopMeasuringOnWindowResize = measureOnWindowResize(onEnd);
onEnd();

let resizeObserver: ResizeObserver | null = null;
Expand All @@ -115,7 +124,7 @@ export function NativeSafeAreaProvider({

return () => {
element.removeEventListener(getSupportedTransitionEvent(), onEnd);
window.removeEventListener('resize', onEnd);
stopMeasuringOnWindowResize();
resizeObserver?.disconnect();
element.remove();
};
Expand All @@ -128,6 +137,40 @@ export function NativeSafeAreaProvider({
);
}

function areMetricsEqual(reported: Metrics, measured: Metrics): boolean {
return (
reported.insets.top === measured.insets.top &&
reported.insets.right === measured.insets.right &&
reported.insets.bottom === measured.insets.bottom &&
reported.insets.left === measured.insets.left &&
reported.frame.x === measured.frame.x &&
reported.frame.y === measured.frame.y &&
reported.frame.width === measured.frame.width &&
reported.frame.height === measured.frame.height
);
}

// React commits the updates of each event listener separately, so a single
// listener measures every provider and a window resize commits once.
const windowResizeMeasurements = new Set<() => void>();

function measureEveryProvider() {
windowResizeMeasurements.forEach((measure) => measure());
}

function measureOnWindowResize(measure: () => void): () => void {
if (windowResizeMeasurements.size === 0) {
window.addEventListener('resize', measureEveryProvider);
}
windowResizeMeasurements.add(measure);
return () => {
windowResizeMeasurements.delete(measure);
if (windowResizeMeasurements.size === 0) {
window.removeEventListener('resize', measureEveryProvider);
}
};
}

let _supportedTransitionEvent: string | null | undefined = null;
function getSupportedTransitionEvent(): string {
if (_supportedTransitionEvent != null) {
Expand Down
94 changes: 92 additions & 2 deletions src/__tests__/NativeSafeAreaProvider.web-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,21 @@ let root: Root | null = null;
let host: HTMLElement | null = null;
let rectMock: ReturnType<typeof spyOnBoundingClientRect>;

function mountProvider(onInsetsChange: InsetChangeNativeCallback) {
function mount(element: React.ReactElement) {
const newHost = document.createElement('div');
document.body.appendChild(newHost);
const newRoot = createRoot(newHost);
act(() => {
newRoot.render(<NativeSafeAreaProvider onInsetsChange={onInsetsChange} />);
newRoot.render(element);
});
root = newRoot;
host = newHost;
}

function mountProvider(onInsetsChange: InsetChangeNativeCallback) {
mount(<NativeSafeAreaProvider onInsetsChange={onInsetsChange} />);
}

describe('NativeSafeAreaProvider.web', () => {
beforeEach(() => {
ResizeObserverMock.instances = [];
Expand Down Expand Up @@ -244,6 +248,92 @@ describe('NativeSafeAreaProvider.web', () => {
});
});

it('measures a window resize that moves the provider without resizing it', () => {
setWindowDimensions(1280, WINDOW_HEIGHT);
rectMock.mockReturnValue(makeRect(340, 0, 600, WINDOW_HEIGHT));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
// A centered provider moves as the window narrows, and no element it
// observes changes size.
setWindowDimensions(900, WINDOW_HEIGHT);
rectMock.mockReturnValue(makeRect(150, 0, 600, WINDOW_HEIGHT));
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(lastMetrics(onInsetsChange).frame).toEqual({
x: 150,
y: 0,
width: 600,
height: WINDOW_HEIGHT,
});
});

it('reports a window resize that changes the insets and leaves the frame as it was', () => {
rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
mockWindowInsets({ top: 0, bottom: 21, left: 44, right: 44 });
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(lastMetrics(onInsetsChange)).toEqual({
insets: { top: 0, bottom: 21, left: 44, right: 44 },
frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT },
});
});

it('does not report metrics that did not change', () => {
rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
const callCount = onInsetsChange.mock.calls.length;
triggerResizeObservers();
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(onInsetsChange).toHaveBeenCalledTimes(callCount);
});

it('measures nested providers from one window resize listener', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const outerInsetsChange = jest.fn<InsetChangeNativeCallback>();
const innerInsetsChange = jest.fn<InsetChangeNativeCallback>();
mount(
<NativeSafeAreaProvider onInsetsChange={outerInsetsChange}>
<NativeSafeAreaProvider onInsetsChange={innerInsetsChange} />
</NativeSafeAreaProvider>,
);
const resizeListeners = addEventListenerSpy.mock.calls
.filter(([type]) => type === 'resize')
.map(([, listener]) => listener);
expect(resizeListeners).toHaveLength(1);

setWindowDimensions(1280, 800);
rectMock.mockReturnValue(makeRect(0, 0, 1280, 800));
act(() => {
window.dispatchEvent(new Event('resize'));
});
for (const onInsetsChange of [outerInsetsChange, innerInsetsChange]) {
expect(lastMetrics(onInsetsChange).frame).toEqual({
x: 0,
y: 0,
width: 1280,
height: 800,
});
}

const currentRoot = root;
act(() => {
currentRoot?.unmount();
});
root = null;
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'resize',
resizeListeners[0],
);
});

it('does not update metrics on a position-only change (known limitation)', () => {
rectMock.mockReturnValue(makeRect(0, 100, 200, 300));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
Expand Down