From f733f1c957b128d5e3f49ef46e0313d234529fc4 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 16:21:04 +0700 Subject: [PATCH] feat(web): add unstable_disableViewOnWeb to SafeAreaProvider On web the provider always renders a wrapping View, which is not always wanted, for example when the provider is mounted at the root of an app by ExpoRoot. Add an undocumented, unstable opt out that renders children directly instead. Since there is no view to measure when the wrapper is gone, insets and frame fall back to window values, which is the existing behaviour of the branch that runs when the provider element cannot be measured. For the same reason style and the ref added in #775 have no element to apply to, so ref stays null while the prop is set. The prop is web only. The default path and every other platform are unchanged. --- src/NativeSafeAreaProvider.web.tsx | 5 ++ src/SafeArea.types.ts | 7 ++ src/SafeAreaContext.tsx | 7 ++ .../NativeSafeAreaProvider.web-test.tsx | 72 ++++++++++++++++++- 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/NativeSafeAreaProvider.web.tsx b/src/NativeSafeAreaProvider.web.tsx index 5bcdf5cd..3f9cb2e0 100644 --- a/src/NativeSafeAreaProvider.web.tsx +++ b/src/NativeSafeAreaProvider.web.tsx @@ -17,6 +17,7 @@ export function NativeSafeAreaProvider({ style, onInsetsChange, ref, + unstable_disableViewOnWeb, }: NativeSafeAreaProviderProps) { const viewRef = React.useRef(null); @@ -121,6 +122,10 @@ export function NativeSafeAreaProvider({ }; }, [onInsetsChange]); + if (unstable_disableViewOnWeb) { + return <>{children}; + } + return ( {children} diff --git a/src/SafeArea.types.ts b/src/SafeArea.types.ts index 5672e5d6..efd42505 100644 --- a/src/SafeArea.types.ts +++ b/src/SafeArea.types.ts @@ -35,6 +35,13 @@ export interface NativeSafeAreaProviderProps extends ViewProps { ref?: React.Ref; children?: React.ReactNode; onInsetsChange: InsetChangeNativeCallback; + /** + * Web only, undocumented and unstable. Render children without the wrapping + * view. Since there is no view to measure, insets and frame fall back to + * window values, and `style` and `ref` have nothing to apply to. Has no + * effect on other platforms. + */ + unstable_disableViewOnWeb?: boolean; } export interface NativeSafeAreaViewProps extends ViewProps { diff --git a/src/SafeAreaContext.tsx b/src/SafeAreaContext.tsx index 72f2c0b3..bb03b94f 100644 --- a/src/SafeAreaContext.tsx +++ b/src/SafeAreaContext.tsx @@ -35,6 +35,13 @@ export interface SafeAreaProviderProps extends ViewProps { * @deprecated */ initialSafeAreaInsets?: EdgeInsets | null; + /** + * Web only, undocumented and unstable. Render children without the wrapping + * view. Since there is no view to measure, insets and frame fall back to + * window values, and `style` and `ref` have nothing to apply to. Has no + * effect on other platforms. + */ + unstable_disableViewOnWeb?: boolean; } export function SafeAreaProvider({ diff --git a/src/__tests__/NativeSafeAreaProvider.web-test.tsx b/src/__tests__/NativeSafeAreaProvider.web-test.tsx index c34aaf4b..498f5db5 100644 --- a/src/__tests__/NativeSafeAreaProvider.web-test.tsx +++ b/src/__tests__/NativeSafeAreaProvider.web-test.tsx @@ -13,10 +13,12 @@ import { import * as React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import type { View } from 'react-native'; import type { EdgeInsets, InsetChangeNativeCallback, Metrics, + NativeSafeAreaProviderProps, } from '../SafeArea.types'; import { NativeSafeAreaProvider } from '../NativeSafeAreaProvider.web'; @@ -123,12 +125,17 @@ let root: Root | null = null; let host: HTMLElement | null = null; let rectMock: ReturnType; -function mountProvider(onInsetsChange: InsetChangeNativeCallback) { +function mountProvider( + onInsetsChange: InsetChangeNativeCallback, + props?: Partial, +) { const newHost = document.createElement('div'); document.body.appendChild(newHost); const newRoot = createRoot(newHost); act(() => { - newRoot.render(); + newRoot.render( + , + ); }); root = newRoot; host = newHost; @@ -281,4 +288,65 @@ describe('NativeSafeAreaProvider.web', () => { frame: { x: 0, y: 0, width: 800, height: 600 }, }); }); + + describe('unstable_disableViewOnWeb', () => { + it('wraps children in a view by default', () => { + const onInsetsChange = jest.fn(); + mountProvider(onInsetsChange, { children: }); + expect(host?.innerHTML).toBe('
'); + }); + + it('renders children without a wrapping element when enabled', () => { + const onInsetsChange = jest.fn(); + mountProvider(onInsetsChange, { + unstable_disableViewOnWeb: true, + children: , + }); + expect(host?.innerHTML).toBe(''); + }); + + it('attaches ref to the wrapping view by default', () => { + const onInsetsChange = jest.fn(); + const ref = React.createRef(); + mountProvider(onInsetsChange, { ref, children: }); + expect(ref.current).not.toBeNull(); + }); + + it('leaves ref null when enabled, since there is no view to attach', () => { + const onInsetsChange = jest.fn(); + const ref = React.createRef(); + mountProvider(onInsetsChange, { + ref, + unstable_disableViewOnWeb: true, + children: , + }); + expect(ref.current).toBeNull(); + }); + + it('reports window insets and frame when enabled', () => { + // Element rects are still measurable, but there is no provider view to + // measure, so metrics come from the window rather than from this rect. + rectMock.mockReturnValue(makeRect(20, 30, 200, 300)); + const onInsetsChange = jest.fn(); + mountProvider(onInsetsChange, { unstable_disableViewOnWeb: true }); + expect(lastMetrics(onInsetsChange)).toEqual({ + insets: WINDOW_INSETS, + frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT }, + }); + }); + + it('keeps reporting window metrics on resize when enabled', () => { + const onInsetsChange = jest.fn(); + mountProvider(onInsetsChange, { unstable_disableViewOnWeb: true }); + setWindowDimensions(800, 600); + mockWindowInsets({ top: 20, bottom: 10, left: 0, right: 0 }); + act(() => { + window.dispatchEvent(new Event('resize')); + }); + expect(lastMetrics(onInsetsChange)).toEqual({ + insets: { top: 20, bottom: 10, left: 0, right: 0 }, + frame: { x: 0, y: 0, width: 800, height: 600 }, + }); + }); + }); });