diff --git a/src/NativeSafeAreaProvider.web.tsx b/src/NativeSafeAreaProvider.web.tsx index 5bcdf5c..29c9336 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,11 +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); - window.addEventListener('resize', onEnd); + const stopMeasuringOnWindowResize = measureOnWindowResize(onEnd); onEnd(); let resizeObserver: ResizeObserver | null = null; @@ -115,7 +124,7 @@ export function NativeSafeAreaProvider({ return () => { element.removeEventListener(getSupportedTransitionEvent(), onEnd); - window.removeEventListener('resize', onEnd); + stopMeasuringOnWindowResize(); resizeObserver?.disconnect(); element.remove(); }; @@ -128,6 +137,40 @@ export function NativeSafeAreaProvider({ ); } +function areMetricsEqual(reported: Metrics, measured: Metrics): boolean { + return ( + 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) { diff --git a/src/__tests__/NativeSafeAreaProvider.web-test.tsx b/src/__tests__/NativeSafeAreaProvider.web-test.tsx index c34aaf4..17ebbb3 100644 --- a/src/__tests__/NativeSafeAreaProvider.web-test.tsx +++ b/src/__tests__/NativeSafeAreaProvider.web-test.tsx @@ -123,17 +123,21 @@ 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 = []; @@ -244,6 +248,92 @@ describe('NativeSafeAreaProvider.web', () => { }); }); + 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); + // 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, + }); + }); + + 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: { top: 0, bottom: 21, left: 44, right: 44 }, + frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT }, + }); + }); + + 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 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( + + + , + ); + 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')); + }); + 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)', () => { rectMock.mockReturnValue(makeRect(0, 100, 200, 300)); const onInsetsChange = jest.fn();