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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,14 @@
"clang-format": "^1.8.0",
"commitlint": "^19.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-ft-flow": "^3.0.11",
"eslint-plugin-jest": "^28.6.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-testing-library": "^7.5.4",
"husky": "^9.0.11",
"jest": "^29.2.1",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.3.2",
"react": "19.2.3",
"react-dom": "19.2.3",
Expand Down
71 changes: 60 additions & 11 deletions src/NativeSafeAreaProvider.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@ import * as React from 'react';
import { View } from 'react-native';
import type { NativeSafeAreaProviderProps } from './SafeArea.types';

/**
* TODO:
* Currently insets and frame are based on the window and are not
* relative to the provider view. This is inconsistent with iOS and Android.
* However in most cases if the provider view covers the screen this is not
* an issue.
*/

const CSSTransitions: Record<string, string> = {
WebkitTransition: 'webkitTransitionEnd',
Transition: 'transitionEnd',
Expand All @@ -25,42 +17,99 @@ export function NativeSafeAreaProvider({
style,
onInsetsChange,
}: NativeSafeAreaProviderProps) {
const viewRef = React.useRef<View>(null);

React.useEffect(() => {
// Skip for SSR.
if (typeof document === 'undefined') {
return;
}

// On react-native-web the view ref is the underlying DOM element.
const providerElement = viewRef.current as unknown as HTMLElement | null;
// When the provider element cannot be measured or observed, insets and
// frame stay based on the window instead of the provider view.
const canMeasureProviderElement =
typeof ResizeObserver !== 'undefined' &&
providerElement != null &&
typeof providerElement.getBoundingClientRect === 'function';

const element = createContextElement();
document.body.appendChild(element);
const onEnd = () => {
const { paddingTop, paddingBottom, paddingLeft, paddingRight } =
window.getComputedStyle(element);

const insets = {
const windowInsets = {
top: paddingTop ? parseInt(paddingTop, 10) : 0,
bottom: paddingBottom ? parseInt(paddingBottom, 10) : 0,
left: paddingLeft ? parseInt(paddingLeft, 10) : 0,
right: paddingRight ? parseInt(paddingRight, 10) : 0,
};
const frame = {

let insets = windowInsets;
let frame = {
x: 0,
y: 0,
width: document.documentElement.offsetWidth,
height: document.documentElement.offsetHeight,
};

if (canMeasureProviderElement) {
// Match the native implementations: report the provider view's frame
// in viewport coordinates and clamp the window insets to the part
// overlapping the provider view.
const rect = providerElement.getBoundingClientRect();
insets = {
top: Math.max(0, windowInsets.top - rect.top),
bottom: Math.max(
0,
windowInsets.bottom - (window.innerHeight - rect.bottom),
),
left: Math.max(0, windowInsets.left - rect.left),
right: Math.max(
0,
windowInsets.right - (window.innerWidth - rect.right),
),
};
frame = {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
};
}

// @ts-ignore: missing properties
onInsetsChange({ nativeEvent: { insets, frame } });
};
element.addEventListener(getSupportedTransitionEvent(), onEnd);
onEnd();

let resizeObserver: ResizeObserver | null = null;
if (canMeasureProviderElement) {
resizeObserver = new ResizeObserver(onEnd);
resizeObserver.observe(providerElement);
// Capture-phase listener so scrolling any ancestor re-measures the
// provider element position.
window.addEventListener('scroll', onEnd, true);
}

return () => {
document.body.removeChild(element);
element.removeEventListener(getSupportedTransitionEvent(), onEnd);
if (resizeObserver != null) {
resizeObserver.disconnect();
window.removeEventListener('scroll', onEnd, true);
}
};
}, [onInsetsChange]);

return <View style={style}>{children}</View>;
return (
<View ref={viewRef} style={style}>
{children}
</View>
);
}

let _supportedTransitionEvent: string | null | undefined = null;
Expand Down
249 changes: 249 additions & 0 deletions src/__tests__/NativeSafeAreaProviderWeb-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/**
* @jest-environment jsdom
*/
/* eslint-disable testing-library/no-unnecessary-act -- this file renders with react-dom, not testing-library */
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from '@jest/globals';
import * as React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { InsetChangeNativeCallback, Metrics } from '../SafeArea.types';
import { NativeSafeAreaProvider } from '../NativeSafeAreaProvider.web';

jest.mock('react-native', () => {
const ReactActual = jest.requireActual<typeof React>('react');
return {
View: ReactActual.forwardRef<
HTMLDivElement,
{ children?: React.ReactNode }
>(function View({ children }, ref) {
return ReactActual.createElement('div', { ref }, children);
}),
};
});

(
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

const WINDOW_WIDTH = 1024;
const WINDOW_HEIGHT = 768;
const WINDOW_INSETS = { top: 44, bottom: 34, left: 10, right: 20 };

class ResizeObserverMock {
static instances: ResizeObserverMock[] = [];
callback: () => void;
constructor(callback: () => void) {
this.callback = callback;
ResizeObserverMock.instances.push(this);
}
observe() {}
unobserve() {}
disconnect() {}
}

function triggerResizeObservers() {
act(() => {
ResizeObserverMock.instances.forEach((instance) => instance.callback());
});
}

function makeRect(
x: number,
y: number,
width: number,
height: number,
): DOMRect {
return {
x,
y,
width,
height,
top: y,
left: x,
right: x + width,
bottom: y + height,
toJSON: () => null,
} as DOMRect;
}

type OnInsetsChangeMock = ReturnType<typeof jest.fn<InsetChangeNativeCallback>>;

function lastMetrics(onInsetsChange: OnInsetsChangeMock): Metrics {
const lastCall = onInsetsChange.mock.lastCall;
if (lastCall == null) {
throw new Error('onInsetsChange was not called');
}
return lastCall[0].nativeEvent;
}

function spyOnBoundingClientRect() {
return jest.spyOn(Element.prototype, 'getBoundingClientRect');
}

let root: Root | null = null;
let host: HTMLElement | null = null;
let rectMock: ReturnType<typeof spyOnBoundingClientRect>;

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

describe('NativeSafeAreaProvider.web', () => {
beforeEach(() => {
ResizeObserverMock.instances = [];
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
ResizeObserverMock;
Object.defineProperty(window, 'innerWidth', {
value: WINDOW_WIDTH,
configurable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: WINDOW_HEIGHT,
configurable: true,
});
Object.defineProperty(document.documentElement, 'offsetWidth', {
value: WINDOW_WIDTH,
configurable: true,
});
Object.defineProperty(document.documentElement, 'offsetHeight', {
value: WINDOW_HEIGHT,
configurable: true,
});
jest.spyOn(window, 'getComputedStyle').mockReturnValue({
paddingTop: `${WINDOW_INSETS.top}px`,
paddingBottom: `${WINDOW_INSETS.bottom}px`,
paddingLeft: `${WINDOW_INSETS.left}px`,
paddingRight: `${WINDOW_INSETS.right}px`,
} as CSSStyleDeclaration);
rectMock = spyOnBoundingClientRect().mockReturnValue(
makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT),
);
});

afterEach(() => {
const currentRoot = root;
if (currentRoot != null) {
act(() => {
currentRoot.unmount();
});
root = null;
}
host?.remove();
host = null;
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
jest.restoreAllMocks();
});

it('reports the provider element rect as the frame', () => {
rectMock.mockReturnValue(makeRect(20, 30, 200, 300));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
expect(lastMetrics(onInsetsChange).frame).toEqual({
x: 20,
y: 30,
width: 200,
height: 300,
});
});

it('reports zero insets when the provider element does not overlap the safe area', () => {
// Element is 100px from the top edge, 168px from the bottom edge, 50px
// from the left edge and 74px from the right edge, all larger than the
// window insets.
rectMock.mockReturnValue(makeRect(50, 100, 900, 500));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
expect(lastMetrics(onInsetsChange).insets).toEqual({
top: 0,
bottom: 0,
left: 0,
right: 0,
});
});

it('clamps insets to the part of the safe area overlapping the provider element', () => {
// Element is 20px from the top edge, 14px from the bottom edge, 4px from
// the left edge and 8px from the right edge.
rectMock.mockReturnValue(makeRect(4, 20, 1012, 734));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
expect(lastMetrics(onInsetsChange).insets).toEqual({
top: WINDOW_INSETS.top - 20,
bottom: WINDOW_INSETS.bottom - 14,
left: WINDOW_INSETS.left - 4,
right: WINDOW_INSETS.right - 8,
});
});

it('reports window insets and frame for a full-viewport provider', () => {
rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
expect(lastMetrics(onInsetsChange)).toEqual({
insets: WINDOW_INSETS,
frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT },
});
});

it('updates metrics when the provider element resizes', () => {
rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
rectMock.mockReturnValue(makeRect(0, 100, WINDOW_WIDTH, 668));
triggerResizeObservers();
expect(lastMetrics(onInsetsChange)).toEqual({
insets: {
top: 0,
bottom: WINDOW_INSETS.bottom,
left: WINDOW_INSETS.left,
right: WINDOW_INSETS.right,
},
frame: { x: 0, y: 100, width: WINDOW_WIDTH, height: 668 },
});
});

it('updates metrics when an ancestor scroll moves the provider element', () => {
rectMock.mockReturnValue(makeRect(0, 100, 200, 300));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
// Position-only change, the element moves up by 80px without resizing.
rectMock.mockReturnValue(makeRect(0, 20, 200, 300));
act(() => {
document.dispatchEvent(new Event('scroll'));
});
expect(lastMetrics(onInsetsChange)).toEqual({
insets: {
top: WINDOW_INSETS.top - 20,
bottom: 0,
left: WINDOW_INSETS.left,
right: 0,
},
frame: { x: 0, y: 20, width: 200, height: 300 },
});
});

it('falls back to window metrics when ResizeObserver is not available', () => {
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
rectMock.mockReturnValue(makeRect(20, 30, 200, 300));
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
mountProvider(onInsetsChange);
expect(lastMetrics(onInsetsChange)).toEqual({
insets: WINDOW_INSETS,
frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT },
});
});
});
Loading
Loading