From cc00813de406a09a14101bc543fdfe4f12046875 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 22 Jul 2026 21:59:58 +0200 Subject: [PATCH 1/2] fix: guard against bogus measureInWindow values --- .../Message/utils/measureInWindow.ts | 63 +++++++++++++++++-- .../UIComponents/PortalWhileClosingView.tsx | 23 +++---- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/package/src/components/Message/utils/measureInWindow.ts b/package/src/components/Message/utils/measureInWindow.ts index fb22c07e49..4c2f749562 100644 --- a/package/src/components/Message/utils/measureInWindow.ts +++ b/package/src/components/Message/utils/measureInWindow.ts @@ -1,20 +1,71 @@ import React from 'react'; -import { Platform, View } from 'react-native'; +import { Dimensions, Platform, View } from 'react-native'; import { EdgeInsets } from 'react-native-safe-area-context'; +type MeasuredRect = { x: number; y: number; w: number; h: number }; + +/** + * How many screen lengths away from the origin a measured coordinate may fall before we treat it as + * bogus. Real targets (a view currently on screen) always measure well within one screen and the failure + * mode we guard against is off by tens of screens, so a generous multiplier cleanly separates the two + * with no risk of false positives on legitimate values. + */ +const SCREEN_BOUND_MULTIPLIER = 2; + +/** + * `measureInWindow` can return wildly out of bounds coordinates on Android when the native window has + * been forced edge-to-edge behind React Native's back (i.e a library setting `FLAG_LAYOUT_NO_LIMITS`, + * as `react-native-system-navigation-bar` does for a transparent nav bar) while RN's own edge-to-edge + * (`edgeToEdgeEnabled`) is off. In that state the window relative measurement is corrupted and would + * return a position of the View far offscreen. + */ +const isMeasuredRectBogus = (x: number, y: number, w: number, h: number): boolean => { + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) { + return true; + } + if (w <= 0 || h <= 0) { + return true; + } + const { width, height } = Dimensions.get('screen'); + if (width <= 0 || height <= 0) { + // can't reason about bounds, trust the measurement + return false; + } + return ( + Math.abs(x) > width * SCREEN_BOUND_MULTIPLIER || Math.abs(y) > height * SCREEN_BOUND_MULTIPLIER + ); +}; + export const measureInWindow = ( node: React.RefObject, insets: EdgeInsets, -): Promise<{ x: number; y: number; w: number; h: number }> => { +): Promise => { return new Promise((resolve, reject) => { const handle = node.current; - if (!handle) + if (!handle) { return reject( new Error('The native handle could not be found while invoking measureInWindow.'), ); + } + + handle.measureInWindow((x, y, w, h) => { + if (!isMeasuredRectBogus(x, y, w, h)) { + resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }); + return; + } + + // If `measureInWindow` returned an out of bounds rect, fallback to `measure()`, whose + // `pageX`/`pageY` are relative to the app root and are the same coordinate space as the window. + // They will stays correct when the window frame has been mutated out from under + // React Native. + if (typeof handle.measure !== 'function') { + resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }); + return; + } - handle.measureInWindow((x, y, w, h) => - resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }), - ); + handle.measure((_x, _y, width, height, pageX, pageY) => { + resolve({ h: height, w: width, x: pageX, y: pageY }); + }); + }); }); }; diff --git a/package/src/components/UIComponents/PortalWhileClosingView.tsx b/package/src/components/UIComponents/PortalWhileClosingView.tsx index 2ae6292bfa..4d117615b6 100644 --- a/package/src/components/UIComponents/PortalWhileClosingView.tsx +++ b/package/src/components/UIComponents/PortalWhileClosingView.tsx @@ -1,10 +1,11 @@ import React, { ReactNode, useEffect, useMemo, useRef } from 'react'; -import { Platform, View } from 'react-native'; +import { View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; +import { measureInWindow } from '../../components/Message/utils/measureInWindow'; import { useStableCallback } from '../../hooks'; import { clearClosingPortalLayout, @@ -124,20 +125,14 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => { return; } - containerRef.current?.measureInWindow((x, y, width, height) => { - const absolute = { - x, - y: y + (Platform.OS === 'android' ? insets.top : 0), - }; - - placeholderLayout.value = { h: height, w: width }; - - setClosingPortalLayout(portalHostName, registrationId, { - ...absolute, - h: height, - w: width, + measureInWindow(containerRef, insets) + .then((rect) => { + placeholderLayout.value = { h: rect.h, w: rect.w }; + setClosingPortalLayout(portalHostName, registrationId, rect); + }) + .catch(() => { + // the container isn't measurable yet; the next layout/effect pass will retry }); - }); }); useEffect(() => { From 9027ac84fb548a19a9600202c0f895b54d497ce4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 22 Jul 2026 22:25:12 +0200 Subject: [PATCH 2/2] chore: add tests for both measurement utils --- .../utils/__tests__/measureInWindow.test.ts | 178 ++++++++++++++++++ .../Message/utils/measureInWindow.ts | 2 +- 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 package/src/components/Message/utils/__tests__/measureInWindow.test.ts diff --git a/package/src/components/Message/utils/__tests__/measureInWindow.test.ts b/package/src/components/Message/utils/__tests__/measureInWindow.test.ts new file mode 100644 index 0000000000..76eb1fd169 --- /dev/null +++ b/package/src/components/Message/utils/__tests__/measureInWindow.test.ts @@ -0,0 +1,178 @@ +import { Dimensions, Platform, View } from 'react-native'; + +import type { EdgeInsets } from 'react-native-safe-area-context'; + +import { isMeasuredRectBogus, measureInWindow } from '../measureInWindow'; + +// `measureInWindow` is globally mocked in jest-setup so other suites don't hit native +// measurement; this suite exercises the real implementation. +jest.unmock('../measureInWindow'); + +// screen 400x800 => bogus threshold is |x| > 800 and |y| > 1600 (2x each) +const SCREEN = { fontScale: 1, height: 800, scale: 2, width: 400 }; +const INSETS: EdgeInsets = { bottom: 10, left: 0, right: 0, top: 24 }; + +const setPlatform = (os: typeof Platform.OS) => { + Object.defineProperty(Platform, 'OS', { configurable: true, get: () => os }); +}; + +type MeasureInWindowTuple = [number, number, number, number]; +type MeasureTuple = [number, number, number, number, number, number]; + +/** + * Builds a fake native handle whose `measureInWindow`/`measure` callbacks fire synchronously + * (as Fabric does). Omitting `measure` produces a handle without the method, exercising the + * "no fallback available" branch. + */ +const makeNode = ({ + measure, + measureInWindow: miw, +}: { + measure?: MeasureTuple; + measureInWindow: MeasureInWindowTuple; +}): { current: View | null } => { + const handle: Record = { + measureInWindow: (cb: (...args: MeasureInWindowTuple) => void) => cb(...miw), + }; + if (measure) { + handle.measure = (cb: (...args: MeasureTuple) => void) => cb(...measure); + } + return { current: handle as unknown as View }; +}; + +describe('isMeasuredRectBogus', () => { + beforeEach(() => { + jest.spyOn(Dimensions, 'get').mockReturnValue(SCREEN); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('accepts a normal on-screen rect', () => { + expect(isMeasuredRectBogus(50, 300, 200, 40)).toBe(false); + }); + + it('accepts a rect within the 2x margin (e.g. partially off-screen)', () => { + expect(isMeasuredRectBogus(50, 1500, 200, 40)).toBe(false); + }); + + it.each([ + ['NaN x', [NaN, 10, 20, 20]], + ['NaN y', [10, NaN, 20, 20]], + ['Infinity x', [Infinity, 10, 20, 20]], + ['-Infinity y', [10, -Infinity, 20, 20]], + ['NaN width', [10, 10, NaN, 20]], + ['Infinity height', [10, 10, 20, Infinity]], + ] as [string, MeasureInWindowTuple][])('flags non-finite values: %s', (_label, [x, y, w, h]) => { + expect(isMeasuredRectBogus(x, y, w, h)).toBe(true); + }); + + it.each([ + ['zero width', [10, 10, 0, 40]], + ['zero height', [10, 10, 40, 0]], + ['negative width', [10, 10, -5, 40]], + ['negative height', [10, 10, 40, -5]], + ] as [string, MeasureInWindowTuple][])( + 'flags non-positive dimensions: %s', + (_label, [x, y, w, h]) => { + expect(isMeasuredRectBogus(x, y, w, h)).toBe(true); + }, + ); + + it.each([ + ['huge y (the FLAG_LAYOUT_NO_LIMITS failure)', [50, 29000, 40, 36]], + ['huge x', [29000, 300, 40, 36]], + ['huge negative x', [-2000, 300, 40, 36]], + ['huge negative y', [50, -2000, 40, 36]], + ] as [string, MeasureInWindowTuple][])( + 'flags coordinates beyond 2x the screen: %s', + (_label, [x, y, w, h]) => { + expect(isMeasuredRectBogus(x, y, w, h)).toBe(true); + }, + ); + + it('treats the exact 2x boundary as valid and just past it as bogus', () => { + // bounds are 800 (x) and 1600 (y); the check uses strict `>` + expect(isMeasuredRectBogus(800, 1600, 40, 36)).toBe(false); + expect(isMeasuredRectBogus(801, 300, 40, 36)).toBe(true); + expect(isMeasuredRectBogus(50, 1601, 40, 36)).toBe(true); + }); + + it('trusts the measurement when screen dimensions are unavailable', () => { + jest.spyOn(Dimensions, 'get').mockReturnValue({ ...SCREEN, height: 0, width: 0 }); + expect(isMeasuredRectBogus(99999, 99999, 40, 36)).toBe(false); + }); +}); + +describe('measureInWindow', () => { + const originalOS = Platform.OS; + + beforeEach(() => { + jest.spyOn(Dimensions, 'get').mockReturnValue(SCREEN); + }); + + afterEach(() => { + jest.restoreAllMocks(); + setPlatform(originalOS); + }); + + it('rejects when the node is not mounted', async () => { + await expect(measureInWindow({ current: null }, INSETS)).rejects.toThrow(/native handle/); + }); + + describe('healthy measurement (primary path)', () => { + it('resolves with the window rect unchanged on iOS', async () => { + setPlatform('ios'); + const node = makeNode({ measureInWindow: [10, 300, 200, 40] }); + await expect(measureInWindow(node, INSETS)).resolves.toEqual({ + h: 40, + w: 200, + x: 10, + y: 300, + }); + }); + + it('compensates by insets.top on Android', async () => { + setPlatform('android'); + const node = makeNode({ measureInWindow: [10, 300, 200, 40] }); + await expect(measureInWindow(node, INSETS)).resolves.toEqual({ + h: 40, + w: 200, + x: 10, + y: 324, + }); + }); + }); + + describe('bogus measurement (measure() fallback)', () => { + it('falls back to root-relative pageX/pageY', async () => { + setPlatform('ios'); + const node = makeNode({ + measure: [0, 0, 64, 36, 12, 717], + measureInWindow: [28903, 29088, 64, 36], + }); + await expect(measureInWindow(node, INSETS)).resolves.toEqual({ h: 36, w: 64, x: 12, y: 717 }); + }); + + it('does not add insets.top to the fallback, since pageY already accounts for it', async () => { + setPlatform('android'); + const node = makeNode({ + measure: [0, 0, 64, 36, 12, 717], + measureInWindow: [28903, 29088, 64, 36], + }); + await expect(measureInWindow(node, INSETS)).resolves.toEqual({ h: 36, w: 64, x: 12, y: 717 }); + }); + + it('returns the compensated window rect when no measure() fallback is available', async () => { + setPlatform('android'); + const node = makeNode({ measureInWindow: [28903, 29088, 64, 36] }); + await expect(measureInWindow(node, INSETS)).resolves.toEqual({ + h: 36, + w: 64, + x: 28903, + y: 29112, + }); + }); + }); +}); diff --git a/package/src/components/Message/utils/measureInWindow.ts b/package/src/components/Message/utils/measureInWindow.ts index 4c2f749562..ba6636b293 100644 --- a/package/src/components/Message/utils/measureInWindow.ts +++ b/package/src/components/Message/utils/measureInWindow.ts @@ -19,7 +19,7 @@ const SCREEN_BOUND_MULTIPLIER = 2; * (`edgeToEdgeEnabled`) is off. In that state the window relative measurement is corrupted and would * return a position of the View far offscreen. */ -const isMeasuredRectBogus = (x: number, y: number, w: number, h: number): boolean => { +export const isMeasuredRectBogus = (x: number, y: number, w: number, h: number): boolean => { if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) { return true; }