Skip to content

Commit 9b7055e

Browse files
fix: make nested SafeAreaProvider report its own frame and insets on web
1 parent ae0e83f commit 9b7055e

4 files changed

Lines changed: 620 additions & 16 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,14 @@
7575
"clang-format": "^1.8.0",
7676
"commitlint": "^19.3.0",
7777
"eslint": "^8.57.0",
78+
"eslint-config-prettier": "^9.1.0",
7879
"eslint-plugin-ft-flow": "^3.0.11",
7980
"eslint-plugin-jest": "^28.6.0",
80-
"eslint-config-prettier": "^9.1.0",
8181
"eslint-plugin-prettier": "^5.1.3",
8282
"eslint-plugin-testing-library": "^7.5.4",
8383
"husky": "^9.0.11",
8484
"jest": "^29.2.1",
85+
"jest-environment-jsdom": "^29.7.0",
8586
"prettier": "^3.3.2",
8687
"react": "19.2.3",
8788
"react-dom": "19.2.3",

src/NativeSafeAreaProvider.web.tsx

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,6 @@ import * as React from 'react';
44
import { View } from 'react-native';
55
import type { NativeSafeAreaProviderProps } from './SafeArea.types';
66

7-
/**
8-
* TODO:
9-
* Currently insets and frame are based on the window and are not
10-
* relative to the provider view. This is inconsistent with iOS and Android.
11-
* However in most cases if the provider view covers the screen this is not
12-
* an issue.
13-
*/
14-
157
const CSSTransitions: Record<string, string> = {
168
WebkitTransition: 'webkitTransitionEnd',
179
Transition: 'transitionEnd',
@@ -25,42 +17,95 @@ export function NativeSafeAreaProvider({
2517
style,
2618
onInsetsChange,
2719
}: NativeSafeAreaProviderProps) {
20+
const viewRef = React.useRef<View>(null);
21+
2822
React.useEffect(() => {
2923
// Skip for SSR.
3024
if (typeof document === 'undefined') {
3125
return;
3226
}
3327

28+
// On react-native-web the view ref is the underlying DOM element.
29+
const providerElement = viewRef.current as unknown as HTMLElement | null;
30+
// When the provider element cannot be measured or observed, insets and
31+
// frame stay based on the window instead of the provider view.
32+
const canMeasureProviderElement =
33+
typeof ResizeObserver !== 'undefined' &&
34+
providerElement != null &&
35+
typeof providerElement.getBoundingClientRect === 'function';
36+
3437
const element = createContextElement();
3538
document.body.appendChild(element);
3639
const onEnd = () => {
3740
const { paddingTop, paddingBottom, paddingLeft, paddingRight } =
3841
window.getComputedStyle(element);
3942

40-
const insets = {
43+
const windowInsets = {
4144
top: paddingTop ? parseInt(paddingTop, 10) : 0,
4245
bottom: paddingBottom ? parseInt(paddingBottom, 10) : 0,
4346
left: paddingLeft ? parseInt(paddingLeft, 10) : 0,
4447
right: paddingRight ? parseInt(paddingRight, 10) : 0,
4548
};
46-
const frame = {
49+
50+
let insets = windowInsets;
51+
let frame = {
4752
x: 0,
4853
y: 0,
4954
width: document.documentElement.offsetWidth,
5055
height: document.documentElement.offsetHeight,
5156
};
57+
58+
if (canMeasureProviderElement) {
59+
// Match the native implementations: report the provider view's frame
60+
// in viewport coordinates and clamp the window insets to the part
61+
// overlapping the provider view.
62+
const rect = providerElement.getBoundingClientRect();
63+
insets = {
64+
top: Math.max(0, windowInsets.top - rect.top),
65+
bottom: Math.max(
66+
0,
67+
windowInsets.bottom - (window.innerHeight - rect.bottom),
68+
),
69+
left: Math.max(0, windowInsets.left - rect.left),
70+
right: Math.max(
71+
0,
72+
windowInsets.right - (window.innerWidth - rect.right),
73+
),
74+
};
75+
frame = {
76+
x: rect.left,
77+
y: rect.top,
78+
width: rect.width,
79+
height: rect.height,
80+
};
81+
}
82+
5283
// @ts-ignore: missing properties
5384
onInsetsChange({ nativeEvent: { insets, frame } });
5485
};
5586
element.addEventListener(getSupportedTransitionEvent(), onEnd);
5687
onEnd();
88+
89+
let resizeObserver: ResizeObserver | null = null;
90+
if (canMeasureProviderElement) {
91+
// Note this only observes size changes, position-only changes of the
92+
// provider element do not trigger an update.
93+
resizeObserver = new ResizeObserver(onEnd);
94+
resizeObserver.observe(providerElement);
95+
}
96+
5797
return () => {
5898
document.body.removeChild(element);
5999
element.removeEventListener(getSupportedTransitionEvent(), onEnd);
100+
resizeObserver?.disconnect();
60101
};
61102
}, [onInsetsChange]);
62103

63-
return <View style={style}>{children}</View>;
104+
return (
105+
<View ref={viewRef} style={style}>
106+
{children}
107+
</View>
108+
);
64109
}
65110

66111
let _supportedTransitionEvent: string | null | undefined = null;
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
/* eslint-disable testing-library/no-unnecessary-act -- this file renders with react-dom, not testing-library */
5+
import {
6+
afterEach,
7+
beforeEach,
8+
describe,
9+
expect,
10+
it,
11+
jest,
12+
} from '@jest/globals';
13+
import * as React from 'react';
14+
import { act } from 'react';
15+
import { createRoot, type Root } from 'react-dom/client';
16+
import type { InsetChangeNativeCallback, Metrics } from '../SafeArea.types';
17+
import { NativeSafeAreaProvider } from '../NativeSafeAreaProvider.web';
18+
19+
jest.mock('react-native', () => {
20+
const ReactActual = jest.requireActual<typeof React>('react');
21+
return {
22+
View: ReactActual.forwardRef<
23+
HTMLDivElement,
24+
{ children?: React.ReactNode }
25+
>(function View({ children }, ref) {
26+
return ReactActual.createElement('div', { ref }, children);
27+
}),
28+
};
29+
});
30+
31+
(
32+
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
33+
).IS_REACT_ACT_ENVIRONMENT = true;
34+
35+
const WINDOW_WIDTH = 1024;
36+
const WINDOW_HEIGHT = 768;
37+
const WINDOW_INSETS = { top: 44, bottom: 34, left: 10, right: 20 };
38+
39+
class ResizeObserverMock {
40+
static instances: ResizeObserverMock[] = [];
41+
callback: () => void;
42+
constructor(callback: () => void) {
43+
this.callback = callback;
44+
ResizeObserverMock.instances.push(this);
45+
}
46+
observe() {}
47+
unobserve() {}
48+
disconnect() {}
49+
}
50+
51+
function triggerResizeObservers() {
52+
act(() => {
53+
ResizeObserverMock.instances.forEach((instance) => instance.callback());
54+
});
55+
}
56+
57+
function makeRect(
58+
x: number,
59+
y: number,
60+
width: number,
61+
height: number,
62+
): DOMRect {
63+
return {
64+
x,
65+
y,
66+
width,
67+
height,
68+
top: y,
69+
left: x,
70+
right: x + width,
71+
bottom: y + height,
72+
toJSON: () => null,
73+
} as DOMRect;
74+
}
75+
76+
type OnInsetsChangeMock = ReturnType<typeof jest.fn<InsetChangeNativeCallback>>;
77+
78+
function lastMetrics(onInsetsChange: OnInsetsChangeMock): Metrics {
79+
const lastCall = onInsetsChange.mock.lastCall;
80+
if (lastCall == null) {
81+
throw new Error('onInsetsChange was not called');
82+
}
83+
return lastCall[0].nativeEvent;
84+
}
85+
86+
function spyOnBoundingClientRect() {
87+
return jest.spyOn(Element.prototype, 'getBoundingClientRect');
88+
}
89+
90+
let root: Root | null = null;
91+
let host: HTMLElement | null = null;
92+
let rectMock: ReturnType<typeof spyOnBoundingClientRect>;
93+
94+
function mountProvider(onInsetsChange: InsetChangeNativeCallback) {
95+
const newHost = document.createElement('div');
96+
document.body.appendChild(newHost);
97+
const newRoot = createRoot(newHost);
98+
act(() => {
99+
newRoot.render(<NativeSafeAreaProvider onInsetsChange={onInsetsChange} />);
100+
});
101+
root = newRoot;
102+
host = newHost;
103+
}
104+
105+
describe('NativeSafeAreaProvider.web', () => {
106+
beforeEach(() => {
107+
ResizeObserverMock.instances = [];
108+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
109+
ResizeObserverMock;
110+
Object.defineProperty(window, 'innerWidth', {
111+
value: WINDOW_WIDTH,
112+
configurable: true,
113+
});
114+
Object.defineProperty(window, 'innerHeight', {
115+
value: WINDOW_HEIGHT,
116+
configurable: true,
117+
});
118+
Object.defineProperty(document.documentElement, 'offsetWidth', {
119+
value: WINDOW_WIDTH,
120+
configurable: true,
121+
});
122+
Object.defineProperty(document.documentElement, 'offsetHeight', {
123+
value: WINDOW_HEIGHT,
124+
configurable: true,
125+
});
126+
jest.spyOn(window, 'getComputedStyle').mockReturnValue({
127+
paddingTop: `${WINDOW_INSETS.top}px`,
128+
paddingBottom: `${WINDOW_INSETS.bottom}px`,
129+
paddingLeft: `${WINDOW_INSETS.left}px`,
130+
paddingRight: `${WINDOW_INSETS.right}px`,
131+
} as CSSStyleDeclaration);
132+
rectMock = spyOnBoundingClientRect().mockReturnValue(
133+
makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT),
134+
);
135+
});
136+
137+
afterEach(() => {
138+
const currentRoot = root;
139+
if (currentRoot != null) {
140+
act(() => {
141+
currentRoot.unmount();
142+
});
143+
root = null;
144+
}
145+
host?.remove();
146+
host = null;
147+
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
148+
jest.restoreAllMocks();
149+
});
150+
151+
it('reports the provider element rect as the frame', () => {
152+
rectMock.mockReturnValue(makeRect(20, 30, 200, 300));
153+
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
154+
mountProvider(onInsetsChange);
155+
expect(lastMetrics(onInsetsChange).frame).toEqual({
156+
x: 20,
157+
y: 30,
158+
width: 200,
159+
height: 300,
160+
});
161+
});
162+
163+
it('reports zero insets when the provider element does not overlap the safe area', () => {
164+
// Element is 100px from the top edge, 168px from the bottom edge, 50px
165+
// from the left edge and 74px from the right edge, all larger than the
166+
// window insets.
167+
rectMock.mockReturnValue(makeRect(50, 100, 900, 500));
168+
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
169+
mountProvider(onInsetsChange);
170+
expect(lastMetrics(onInsetsChange).insets).toEqual({
171+
top: 0,
172+
bottom: 0,
173+
left: 0,
174+
right: 0,
175+
});
176+
});
177+
178+
it('clamps insets to the part of the safe area overlapping the provider element', () => {
179+
// Element is 20px from the top edge, 14px from the bottom edge, 4px from
180+
// the left edge and 8px from the right edge.
181+
rectMock.mockReturnValue(makeRect(4, 20, 1012, 734));
182+
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
183+
mountProvider(onInsetsChange);
184+
expect(lastMetrics(onInsetsChange).insets).toEqual({
185+
top: WINDOW_INSETS.top - 20,
186+
bottom: WINDOW_INSETS.bottom - 14,
187+
left: WINDOW_INSETS.left - 4,
188+
right: WINDOW_INSETS.right - 8,
189+
});
190+
});
191+
192+
it('reports window insets and frame for a full-viewport provider', () => {
193+
rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT));
194+
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
195+
mountProvider(onInsetsChange);
196+
expect(lastMetrics(onInsetsChange)).toEqual({
197+
insets: WINDOW_INSETS,
198+
frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT },
199+
});
200+
});
201+
202+
it('updates metrics when the provider element resizes', () => {
203+
rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT));
204+
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
205+
mountProvider(onInsetsChange);
206+
rectMock.mockReturnValue(makeRect(0, 100, WINDOW_WIDTH, 668));
207+
triggerResizeObservers();
208+
expect(lastMetrics(onInsetsChange)).toEqual({
209+
insets: {
210+
top: 0,
211+
bottom: WINDOW_INSETS.bottom,
212+
left: WINDOW_INSETS.left,
213+
right: WINDOW_INSETS.right,
214+
},
215+
frame: { x: 0, y: 100, width: WINDOW_WIDTH, height: 668 },
216+
});
217+
});
218+
219+
it('falls back to window metrics when ResizeObserver is not available', () => {
220+
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
221+
rectMock.mockReturnValue(makeRect(20, 30, 200, 300));
222+
const onInsetsChange = jest.fn<InsetChangeNativeCallback>();
223+
mountProvider(onInsetsChange);
224+
expect(lastMetrics(onInsetsChange)).toEqual({
225+
insets: WINDOW_INSETS,
226+
frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT },
227+
});
228+
});
229+
});

0 commit comments

Comments
 (0)