From 2b500263e5d7fead18575c6952099313e0fdeb17 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. The prop is web only. The default path and every other platform are unchanged. --- src/NativeSafeAreaProvider.web.tsx | 5 ++ src/SafeArea.types.ts | 6 +++ src/SafeAreaContext.tsx | 6 +++ .../NativeSafeAreaProvider.web-test.tsx | 53 ++++++++++++++++++- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/NativeSafeAreaProvider.web.tsx b/src/NativeSafeAreaProvider.web.tsx index 82828693..1fb72147 100644 --- a/src/NativeSafeAreaProvider.web.tsx +++ b/src/NativeSafeAreaProvider.web.tsx @@ -16,6 +16,7 @@ export function NativeSafeAreaProvider({ children, style, onInsetsChange, + unstable_disableViewOnWeb, }: NativeSafeAreaProviderProps) { const viewRef = React.useRef(null); @@ -107,6 +108,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 d3d23b62..45052121 100644 --- a/src/SafeArea.types.ts +++ b/src/SafeArea.types.ts @@ -34,6 +34,12 @@ export type InsetChangeNativeCallback = (event: InsetChangedEvent) => void; export interface NativeSafeAreaProviderProps extends ViewProps { 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. 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 ed192d76..d3a052d1 100644 --- a/src/SafeAreaContext.tsx +++ b/src/SafeAreaContext.tsx @@ -29,6 +29,12 @@ 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. 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..1e0bb247 100644 --- a/src/__tests__/NativeSafeAreaProvider.web-test.tsx +++ b/src/__tests__/NativeSafeAreaProvider.web-test.tsx @@ -17,6 +17,7 @@ import type { EdgeInsets, InsetChangeNativeCallback, Metrics, + NativeSafeAreaProviderProps, } from '../SafeArea.types'; import { NativeSafeAreaProvider } from '../NativeSafeAreaProvider.web'; @@ -123,12 +124,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 +287,47 @@ 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('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 }, + }); + }); + }); });