From 61cd48ce88ea599992e8e744b683a8b798dd353a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 17:45:19 +0300 Subject: [PATCH 1/3] fix: measure a window resize on web after layout, not inside the resize event The resize listener ran before the frame's layout, so reading the provider element's rect there forced a synchronous layout, and when the provider resized with the window its ResizeObserver reported the same values again once layout had run. The context element now spans the viewport and the same observer watches it, so a window resize is measured once, after layout. Without a ResizeObserver the provider keeps the window resize listener. --- src/NativeSafeAreaProvider.web.tsx | 9 ++-- .../NativeSafeAreaProvider.web-test.tsx | 45 +++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/NativeSafeAreaProvider.web.tsx b/src/NativeSafeAreaProvider.web.tsx index 5bcdf5cd..caef0ca3 100644 --- a/src/NativeSafeAreaProvider.web.tsx +++ b/src/NativeSafeAreaProvider.web.tsx @@ -98,7 +98,6 @@ export function NativeSafeAreaProvider({ onInsetsChange({ nativeEvent: { insets, frame } }); }; element.addEventListener(getSupportedTransitionEvent(), onEnd); - window.addEventListener('resize', onEnd); onEnd(); let resizeObserver: ResizeObserver | null = null; @@ -111,6 +110,10 @@ export function NativeSafeAreaProvider({ // per-frame measurement work. resizeObserver = new ResizeObserver(onEnd); resizeObserver.observe(providerElement); + // A window resize resizes the context element, which spans the viewport. + resizeObserver.observe(element, { box: 'border-box' }); + } else { + window.addEventListener('resize', onEnd); } return () => { @@ -175,8 +178,8 @@ function createContextElement(): HTMLElement { style.position = 'fixed'; style.left = '0'; style.top = '0'; - style.width = '0'; - style.height = '0'; + style.right = '0'; + style.bottom = '0'; style.zIndex = '-1'; style.overflow = 'hidden'; style.visibility = 'hidden'; diff --git a/src/__tests__/NativeSafeAreaProvider.web-test.tsx b/src/__tests__/NativeSafeAreaProvider.web-test.tsx index c34aaf4b..25dfd3cd 100644 --- a/src/__tests__/NativeSafeAreaProvider.web-test.tsx +++ b/src/__tests__/NativeSafeAreaProvider.web-test.tsx @@ -43,15 +43,31 @@ const WINDOW_INSETS = { top: 44, bottom: 34, left: 10, right: 20 }; class ResizeObserverMock { static instances: ResizeObserverMock[] = []; callback: () => void; + observed: { target: Element; options?: ResizeObserverOptions }[] = []; constructor(callback: () => void) { this.callback = callback; ResizeObserverMock.instances.push(this); } - observe() {} + observe(target: Element, options?: ResizeObserverOptions) { + this.observed.push({ target, options }); + } unobserve() {} disconnect() {} } +function spansViewport(element: Element): boolean { + if (!(element instanceof HTMLElement)) { + return false; + } + const { position, top, right, bottom, left } = element.style; + return ( + position === 'fixed' && + [top, right, bottom, left].every( + (inset) => inset === '0' || inset === '0px', + ) + ); +} + function triggerResizeObservers() { act(() => { ResizeObserverMock.instances.forEach((instance) => instance.callback()); @@ -228,16 +244,39 @@ describe('NativeSafeAreaProvider.web', () => { }); }); - it('updates metrics on window resize', () => { + it('does not measure inside a window resize event', () => { rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); const onInsetsChange = jest.fn(); mountProvider(onInsetsChange); - // The window grows and the provider element grows with it. + const callCount = onInsetsChange.mock.calls.length; + rectMock.mockClear(); setWindowDimensions(1280, 800); rectMock.mockReturnValue(makeRect(0, 0, 1280, 800)); + // The resize event fires before the frame's layout, so reading the + // element's rect here would force a synchronous layout. act(() => { window.dispatchEvent(new Event('resize')); }); + expect(rectMock).not.toHaveBeenCalled(); + expect(onInsetsChange).toHaveBeenCalledTimes(callCount); + }); + + it('observes an element spanning the viewport, so a window resize is measured after layout', () => { + rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); + const onInsetsChange = jest.fn(); + mountProvider(onInsetsChange); + const viewportObservations = ResizeObserverMock.instances.flatMap( + (instance) => + instance.observed.filter(({ target }) => spansViewport(target)), + ); + expect(viewportObservations).toHaveLength(1); + expect(viewportObservations[0]?.options).toEqual({ box: 'border-box' }); + + // The window grows and the provider element grows with it; the + // observer reports it once layout has run. + setWindowDimensions(1280, 800); + rectMock.mockReturnValue(makeRect(0, 0, 1280, 800)); + triggerResizeObservers(); expect(lastMetrics(onInsetsChange)).toEqual({ insets: WINDOW_INSETS, frame: { x: 0, y: 0, width: 1280, height: 800 }, From 5ac0d0113671b68400ebc3d82b61e33919b22f1e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 21:06:41 +0300 Subject: [PATCH 2/3] fix: keep the window resize listener where ResizeObserver cannot observe a border box Chrome before 84 and Safari before 15.4 ignore the box option and observe the content box, which the context element's env() padding transition resizes, so those engines would report the insets at every step of the transition. They keep the window resize listener, as an engine without ResizeObserver does. --- src/NativeSafeAreaProvider.web.tsx | 11 ++++++ .../NativeSafeAreaProvider.web-test.tsx | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/NativeSafeAreaProvider.web.tsx b/src/NativeSafeAreaProvider.web.tsx index caef0ca3..8ee248a5 100644 --- a/src/NativeSafeAreaProvider.web.tsx +++ b/src/NativeSafeAreaProvider.web.tsx @@ -110,6 +110,8 @@ export function NativeSafeAreaProvider({ // per-frame measurement work. resizeObserver = new ResizeObserver(onEnd); resizeObserver.observe(providerElement); + } + if (resizeObserver != null && canObserveBorderBox()) { // A window resize resizes the context element, which spans the viewport. resizeObserver.observe(element, { box: 'border-box' }); } else { @@ -131,6 +133,15 @@ export function NativeSafeAreaProvider({ ); } +// An engine that ignores the `box` option observes the content box, which the +// env() padding transition resizes; the same engines lack `borderBoxSize`. +function canObserveBorderBox(): boolean { + return ( + typeof ResizeObserverEntry !== 'undefined' && + 'borderBoxSize' in ResizeObserverEntry.prototype + ); +} + let _supportedTransitionEvent: string | null | undefined = null; function getSupportedTransitionEvent(): string { if (_supportedTransitionEvent != null) { diff --git a/src/__tests__/NativeSafeAreaProvider.web-test.tsx b/src/__tests__/NativeSafeAreaProvider.web-test.tsx index 25dfd3cd..0685afa7 100644 --- a/src/__tests__/NativeSafeAreaProvider.web-test.tsx +++ b/src/__tests__/NativeSafeAreaProvider.web-test.tsx @@ -55,6 +55,12 @@ class ResizeObserverMock { disconnect() {} } +class ResizeObserverEntryMock { + get borderBoxSize(): readonly ResizeObserverSize[] { + return []; + } +} + function spansViewport(element: Element): boolean { if (!(element instanceof HTMLElement)) { return false; @@ -155,6 +161,8 @@ describe('NativeSafeAreaProvider.web', () => { ResizeObserverMock.instances = []; (globalThis as { ResizeObserver?: unknown }).ResizeObserver = ResizeObserverMock; + (globalThis as { ResizeObserverEntry?: unknown }).ResizeObserverEntry = + ResizeObserverEntryMock; setWindowDimensions(WINDOW_WIDTH, WINDOW_HEIGHT); mockWindowInsets(WINDOW_INSETS); rectMock = spyOnBoundingClientRect().mockReturnValue( @@ -173,6 +181,8 @@ describe('NativeSafeAreaProvider.web', () => { host?.remove(); host = null; delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + delete (globalThis as { ResizeObserverEntry?: unknown }) + .ResizeObserverEntry; jest.restoreAllMocks(); }); @@ -283,6 +293,32 @@ describe('NativeSafeAreaProvider.web', () => { }); }); + it('measures a window resize from the resize event where ResizeObserver cannot observe a border box', () => { + // An engine that ignores the `box` option would observe the context + // element's content box, which every step of its env() padding + // transition resizes. + (globalThis as { ResizeObserverEntry?: unknown }).ResizeObserverEntry = + class {}; + rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); + const onInsetsChange = jest.fn(); + mountProvider(onInsetsChange); + const viewportObservations = ResizeObserverMock.instances.flatMap( + (instance) => + instance.observed.filter(({ target }) => spansViewport(target)), + ); + expect(viewportObservations).toHaveLength(0); + + setWindowDimensions(1280, 800); + rectMock.mockReturnValue(makeRect(0, 0, 1280, 800)); + act(() => { + window.dispatchEvent(new Event('resize')); + }); + expect(lastMetrics(onInsetsChange)).toEqual({ + insets: WINDOW_INSETS, + frame: { x: 0, y: 0, width: 1280, height: 800 }, + }); + }); + it('does not update metrics on a position-only change (known limitation)', () => { rectMock.mockReturnValue(makeRect(0, 100, 200, 300)); const onInsetsChange = jest.fn(); From 1e765352132ccbc7eb7cd3a0893bca9de4e33939 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 11:01:22 +0300 Subject: [PATCH 3/3] fix: measure a window resize once for every provider and report only changes Measuring a window resize from a ResizeObserver alone painted the new metrics a frame after the resize, because React commits an update made in an observer callback after paint, and it missed a resize that only moves the provider once a transform on body narrowed the viewport-spanning context element. The resize event stays where a window resize is measured. One listener measures every provider, so React commits their updates together, and a provider reports only metrics that changed, as the native providers do, so the ResizeObserver callback that follows a resize commits nothing. --- src/NativeSafeAreaProvider.web.tsx | 59 ++++++-- .../NativeSafeAreaProvider.web-test.tsx | 143 ++++++++++-------- 2 files changed, 123 insertions(+), 79 deletions(-) diff --git a/src/NativeSafeAreaProvider.web.tsx b/src/NativeSafeAreaProvider.web.tsx index 8ee248a5..29c9336c 100644 --- a/src/NativeSafeAreaProvider.web.tsx +++ b/src/NativeSafeAreaProvider.web.tsx @@ -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 = { WebkitTransition: 'webkitTransitionEnd', @@ -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); @@ -94,10 +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); + const stopMeasuringOnWindowResize = measureOnWindowResize(onEnd); onEnd(); let resizeObserver: ResizeObserver | null = null; @@ -111,16 +121,10 @@ export function NativeSafeAreaProvider({ resizeObserver = new ResizeObserver(onEnd); resizeObserver.observe(providerElement); } - if (resizeObserver != null && canObserveBorderBox()) { - // A window resize resizes the context element, which spans the viewport. - resizeObserver.observe(element, { box: 'border-box' }); - } else { - window.addEventListener('resize', onEnd); - } return () => { element.removeEventListener(getSupportedTransitionEvent(), onEnd); - window.removeEventListener('resize', onEnd); + stopMeasuringOnWindowResize(); resizeObserver?.disconnect(); element.remove(); }; @@ -133,15 +137,40 @@ export function NativeSafeAreaProvider({ ); } -// An engine that ignores the `box` option observes the content box, which the -// env() padding transition resizes; the same engines lack `borderBoxSize`. -function canObserveBorderBox(): boolean { +function areMetricsEqual(reported: Metrics, measured: Metrics): boolean { return ( - typeof ResizeObserverEntry !== 'undefined' && - 'borderBoxSize' in ResizeObserverEntry.prototype + 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) { @@ -189,8 +218,8 @@ function createContextElement(): HTMLElement { style.position = 'fixed'; style.left = '0'; style.top = '0'; - style.right = '0'; - style.bottom = '0'; + style.width = '0'; + style.height = '0'; style.zIndex = '-1'; style.overflow = 'hidden'; style.visibility = 'hidden'; diff --git a/src/__tests__/NativeSafeAreaProvider.web-test.tsx b/src/__tests__/NativeSafeAreaProvider.web-test.tsx index 0685afa7..17ebbb3b 100644 --- a/src/__tests__/NativeSafeAreaProvider.web-test.tsx +++ b/src/__tests__/NativeSafeAreaProvider.web-test.tsx @@ -43,37 +43,15 @@ const WINDOW_INSETS = { top: 44, bottom: 34, left: 10, right: 20 }; class ResizeObserverMock { static instances: ResizeObserverMock[] = []; callback: () => void; - observed: { target: Element; options?: ResizeObserverOptions }[] = []; constructor(callback: () => void) { this.callback = callback; ResizeObserverMock.instances.push(this); } - observe(target: Element, options?: ResizeObserverOptions) { - this.observed.push({ target, options }); - } + observe() {} unobserve() {} disconnect() {} } -class ResizeObserverEntryMock { - get borderBoxSize(): readonly ResizeObserverSize[] { - return []; - } -} - -function spansViewport(element: Element): boolean { - if (!(element instanceof HTMLElement)) { - return false; - } - const { position, top, right, bottom, left } = element.style; - return ( - position === 'fixed' && - [top, right, bottom, left].every( - (inset) => inset === '0' || inset === '0px', - ) - ); -} - function triggerResizeObservers() { act(() => { ResizeObserverMock.instances.forEach((instance) => instance.callback()); @@ -145,24 +123,26 @@ let root: Root | null = null; let host: HTMLElement | null = null; let rectMock: ReturnType; -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(); + newRoot.render(element); }); root = newRoot; host = newHost; } +function mountProvider(onInsetsChange: InsetChangeNativeCallback) { + mount(); +} + describe('NativeSafeAreaProvider.web', () => { beforeEach(() => { ResizeObserverMock.instances = []; (globalThis as { ResizeObserver?: unknown }).ResizeObserver = ResizeObserverMock; - (globalThis as { ResizeObserverEntry?: unknown }).ResizeObserverEntry = - ResizeObserverEntryMock; setWindowDimensions(WINDOW_WIDTH, WINDOW_HEIGHT); mockWindowInsets(WINDOW_INSETS); rectMock = spyOnBoundingClientRect().mockReturnValue( @@ -181,8 +161,6 @@ describe('NativeSafeAreaProvider.web', () => { host?.remove(); host = null; delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; - delete (globalThis as { ResizeObserverEntry?: unknown }) - .ResizeObserverEntry; jest.restoreAllMocks(); }); @@ -254,69 +232,106 @@ describe('NativeSafeAreaProvider.web', () => { }); }); - it('does not measure inside a window resize event', () => { + it('updates metrics on window resize', () => { rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); const onInsetsChange = jest.fn(); mountProvider(onInsetsChange); - const callCount = onInsetsChange.mock.calls.length; - rectMock.mockClear(); + // The window grows and the provider element grows with it. setWindowDimensions(1280, 800); rectMock.mockReturnValue(makeRect(0, 0, 1280, 800)); - // The resize event fires before the frame's layout, so reading the - // element's rect here would force a synchronous layout. act(() => { window.dispatchEvent(new Event('resize')); }); - expect(rectMock).not.toHaveBeenCalled(); - expect(onInsetsChange).toHaveBeenCalledTimes(callCount); + expect(lastMetrics(onInsetsChange)).toEqual({ + insets: WINDOW_INSETS, + frame: { x: 0, y: 0, width: 1280, height: 800 }, + }); }); - it('observes an element spanning the viewport, so a window resize is measured after layout', () => { - rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); + 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(); mountProvider(onInsetsChange); - const viewportObservations = ResizeObserverMock.instances.flatMap( - (instance) => - instance.observed.filter(({ target }) => spansViewport(target)), - ); - expect(viewportObservations).toHaveLength(1); - expect(viewportObservations[0]?.options).toEqual({ box: 'border-box' }); + // 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, + }); + }); - // The window grows and the provider element grows with it; the - // observer reports it once layout has run. - setWindowDimensions(1280, 800); - rectMock.mockReturnValue(makeRect(0, 0, 1280, 800)); - triggerResizeObservers(); + 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(); + mountProvider(onInsetsChange); + mockWindowInsets({ top: 0, bottom: 21, left: 44, right: 44 }); + act(() => { + window.dispatchEvent(new Event('resize')); + }); expect(lastMetrics(onInsetsChange)).toEqual({ - insets: WINDOW_INSETS, - frame: { x: 0, y: 0, width: 1280, height: 800 }, + insets: { top: 0, bottom: 21, left: 44, right: 44 }, + frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT }, }); }); - it('measures a window resize from the resize event where ResizeObserver cannot observe a border box', () => { - // An engine that ignores the `box` option would observe the context - // element's content box, which every step of its env() padding - // transition resizes. - (globalThis as { ResizeObserverEntry?: unknown }).ResizeObserverEntry = - class {}; + it('does not report metrics that did not change', () => { rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); const onInsetsChange = jest.fn(); mountProvider(onInsetsChange); - const viewportObservations = ResizeObserverMock.instances.flatMap( - (instance) => - instance.observed.filter(({ target }) => spansViewport(target)), + 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(); + const innerInsetsChange = jest.fn(); + mount( + + + , ); - expect(viewportObservations).toHaveLength(0); + 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')); }); - expect(lastMetrics(onInsetsChange)).toEqual({ - insets: WINDOW_INSETS, - frame: { x: 0, y: 0, width: 1280, height: 800 }, + 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)', () => {