diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000000..c1349c39e737 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in React Native, please report it responsibly. + +**Please do NOT report security vulnerabilities through public GitHub issues.** + +Instead, please report them through [Facebook's Bug Bounty program](https://www.facebook.com/whitehat) or via [GitHub Security Advisories](https://github.com/facebook/react-native/security/advisories/new). + +Please include: +- Type of issue +- Steps to reproduce +- Impact assessment diff --git a/packages/react-native/Libraries/Blob/FileReader.js b/packages/react-native/Libraries/Blob/FileReader.js index 9cb37e5ca487..092968d5e712 100644 --- a/packages/react-native/Libraries/Blob/FileReader.js +++ b/packages/react-native/Libraries/Blob/FileReader.js @@ -72,6 +72,13 @@ class FileReader extends EventTarget { } readAsArrayBuffer(blob: ?Blob): void { + if (this._readyState === LOADING) { + throw new DOMException( + 'The object is in an invalid state.', + 'InvalidStateError', + ); + } + this._aborted = false; if (blob == null) { @@ -80,6 +87,9 @@ class FileReader extends EventTarget { ); } + this._readyState = LOADING; + this.dispatchEvent(new Event('loadstart')); + NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { if (this._aborted) { @@ -103,6 +113,13 @@ class FileReader extends EventTarget { } readAsDataURL(blob: ?Blob): void { + if (this._readyState === LOADING) { + throw new DOMException( + 'The object is in an invalid state.', + 'InvalidStateError', + ); + } + this._aborted = false; if (blob == null) { @@ -111,6 +128,9 @@ class FileReader extends EventTarget { ); } + this._readyState = LOADING; + this.dispatchEvent(new Event('loadstart')); + NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { if (this._aborted) { @@ -130,6 +150,13 @@ class FileReader extends EventTarget { } readAsText(blob: ?Blob, encoding: string = 'UTF-8'): void { + if (this._readyState === LOADING) { + throw new DOMException( + 'The object is in an invalid state.', + 'InvalidStateError', + ); + } + this._aborted = false; if (blob == null) { @@ -138,6 +165,9 @@ class FileReader extends EventTarget { ); } + this._readyState = LOADING; + this.dispatchEvent(new Event('loadstart')); + NativeFileReaderModule.readAsText(blob.data, encoding).then( (text: string) => { if (this._aborted) { diff --git a/packages/react-native/src/private/webapis/dom/observers/ResizeObserver.js b/packages/react-native/src/private/webapis/dom/observers/ResizeObserver.js new file mode 100644 index 000000000000..e65c9e7ec330 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/observers/ResizeObserver.js @@ -0,0 +1,318 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +// flowlint unsafe-getters-setters:off + +import type ReactNativeElement from '../nodes/ReactNativeElement'; + +import { + roundToDevicePixel, + computeContentBoxSize, + computeBorderBoxSize, + computeDevicePixelContentBoxSize, +} from './ResizeObserverUtils'; + +export type ResizeObserverBoxOptions = + | 'content-box' + | 'border-box' + | 'device-pixel-content-box'; + +export interface ResizeObserverOptions { + +box?: ResizeObserverBoxOptions; +} + +export type ResizeObserverCallback = ( + entries: $ReadOnlyArray, + observer: ResizeObserver, +) => mixed; + +type ResizeObserverSize = { + +inlineSize: number, + +blockSize: number, +}; + +/** + * Represents a single size change observation for a target element. + * + * An array of `ResizeObserverEntry` objects is delivered to the + * `ResizeObserver` callback as the first argument. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry + */ +export class ResizeObserverEntry { + _target: ReactNativeElement; + _contentRect: {x: number, y: number, width: number, height: number}; + _contentBoxSize: $ReadOnlyArray; + _borderBoxSize: $ReadOnlyArray; + _devicePixelContentBoxSize: $ReadOnlyArray; + + constructor(target: ReactNativeElement): void { + this._target = target; + + const layout = target._layout; + const width = layout != null ? layout.width : 0; + const height = layout != null ? layout.height : 0; + + const contentBox = computeContentBoxSize(width, height); + const borderBox = computeBorderBoxSize(width, height); + const devicePixelBox = computeDevicePixelContentBoxSize(width, height); + + this._contentRect = { + x: 0, + y: 0, + width: contentBox.inlineSize, + height: contentBox.blockSize, + }; + + this._contentBoxSize = [contentBox]; + this._borderBoxSize = [borderBox]; + this._devicePixelContentBoxSize = [devicePixelBox]; + } + + /** + * The `ReactNativeElement` being observed. + */ + get target(): ReactNativeElement { + return this._target; + } + + /** + * A DOMRectReadOnly-like object containing the new size of the observed + * element when the callback is run. This uses the content box dimensions. + */ + get contentRect(): {x: number, y: number, width: number, height: number} { + return this._contentRect; + } + + /** + * An array containing the new content box size of the observed element. + */ + get contentBoxSize(): $ReadOnlyArray { + return this._contentBoxSize; + } + + /** + * An array containing the new border box size of the observed element. + */ + get borderBoxSize(): $ReadOnlyArray { + return this._borderBoxSize; + } + + /** + * An array containing the new content box size of the observed element + * in device pixel units. + */ + get devicePixelContentBoxSize(): $ReadOnlyArray { + return this._devicePixelContentBoxSize; + } +} + +type ObservationRecord = { + target: ReactNativeElement, + box: ResizeObserverBoxOptions, + lastReportedWidth: number, + lastReportedHeight: number, +}; + +// Global list of all active ResizeObserver instances for scheduling +const activeObservers: Set = new Set(); + +// Batch scheduling state +let scheduledFrameId: ?AnimationFrameID = null; + +/** + * Process all pending resize observations across all active observers. + * Observations are batched and delivered in a single callback per observer + * per frame to avoid layout thrashing. + */ +function processObservations(): void { + scheduledFrameId = null; + + for (const observer of activeObservers) { + const entries: Array = []; + + for (const record of observer._observations) { + const target = record.target; + + // Skip disconnected elements — null-check prevents crashes + // when an element has been removed from the tree between frames. + const layout = target._layout; + if (layout == null) { + continue; + } + + const currentWidth = roundToDevicePixel(layout.width); + const currentHeight = roundToDevicePixel(layout.height); + + // Only report if dimensions actually changed since last report + if ( + currentWidth !== record.lastReportedWidth || + currentHeight !== record.lastReportedHeight + ) { + record.lastReportedWidth = currentWidth; + record.lastReportedHeight = currentHeight; + entries.push(new ResizeObserverEntry(target)); + } + } + + if (entries.length > 0) { + try { + observer._callback(entries, observer); + } catch (error) { + // Matches browser behavior: errors in callbacks are reported + // but do not prevent other observers from being notified. + console.error( + "Error in ResizeObserver callback: '%s'", + error.message, + ); + } + } + } + + // Reschedule if there are still active observers + if (activeObservers.size > 0) { + scheduleObservationProcessing(); + } +} + +/** + * Schedule a batched processing of all resize observations on the next + * animation frame. Multiple calls within the same frame are coalesced. + */ +function scheduleObservationProcessing(): void { + if (scheduledFrameId == null) { + scheduledFrameId = requestAnimationFrame(processObservations); + } +} + +/** + * React Native implementation of the `ResizeObserver` API. + * + * Reports changes to the dimensions of an element's content or border box. + * Observations are batched and delivered via `requestAnimationFrame` to + * avoid layout thrashing and provide consistent timing. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver + */ +export default class ResizeObserver { + _callback: ResizeObserverCallback; + _observations: Array; + + constructor(callback: ResizeObserverCallback): void { + if (callback == null) { + throw new TypeError( + "Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.", + ); + } + + if (typeof callback !== 'function') { + throw new TypeError( + "Failed to construct 'ResizeObserver': parameter 1 is not of type 'Function'.", + ); + } + + this._callback = callback; + this._observations = []; + } + + /** + * Starts observing the specified `ReactNativeElement`. + * + * If the element is already being observed, the existing observation is + * updated with the new box option. Calling observe with no options + * defaults to `content-box`. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe + */ + observe(target: ReactNativeElement, options?: ResizeObserverOptions): void { + if (target == null) { + throw new TypeError( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + } + + const box: ResizeObserverBoxOptions = options?.box ?? 'content-box'; + + if ( + box !== 'content-box' && + box !== 'border-box' && + box !== 'device-pixel-content-box' + ) { + throw new TypeError( + `Failed to execute 'observe' on 'ResizeObserver': '${box}' is not a valid enum value of type ResizeObserverBoxOptions.`, + ); + } + + // If already observing this target, update the box option per spec + const existingIndex = this._observations.findIndex( + record => record.target === target, + ); + + if (existingIndex !== -1) { + this._observations[existingIndex].box = box; + return; + } + + const layout = target._layout; + const initialWidth = + layout != null ? roundToDevicePixel(layout.width) : -1; + const initialHeight = + layout != null ? roundToDevicePixel(layout.height) : -1; + + this._observations.push({ + target, + box, + // Use -1 to force an initial callback delivery + lastReportedWidth: initialWidth === 0 ? -1 : initialWidth, + lastReportedHeight: initialHeight === 0 ? -1 : initialHeight, + }); + + activeObservers.add(this); + scheduleObservationProcessing(); + } + + /** + * Ends the observing of a specified `ReactNativeElement`. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve + */ + unobserve(target: ReactNativeElement): void { + if (target == null) { + throw new TypeError( + "Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + } + + const index = this._observations.findIndex( + record => record.target === target, + ); + + if (index === -1) { + return; + } + + this._observations.splice(index, 1); + + if (this._observations.length === 0) { + activeObservers.delete(this); + } + } + + /** + * Unobserves all observed elements and deactivates the observer. + * The observer can be reused by calling `observe()` again. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/disconnect + */ + disconnect(): void { + this._observations = []; + activeObservers.delete(this); + } +} diff --git a/packages/react-native/src/private/webapis/dom/observers/ResizeObserverUtils.js b/packages/react-native/src/private/webapis/dom/observers/ResizeObserverUtils.js new file mode 100644 index 000000000000..031a0f37e954 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/observers/ResizeObserverUtils.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {PixelRatio} from 'react-native'; + +type BoxSize = { + +inlineSize: number, + +blockSize: number, +}; + +/** + * Rounds a layout value to the nearest device pixel to avoid fractional pixel + * rounding errors that can cause spurious resize callbacks. + * + * Without this fix, floating-point imprecision in layout values (e.g., + * 100.00000001 vs 100.0) could cause the observer to report a size change + * when the rendered size hasn't actually changed on screen. + * + * The rounding is performed at device-pixel granularity so that the reported + * size matches what is actually rendered on the display. + */ +export function roundToDevicePixel(value: number): number { + if (value == null || !Number.isFinite(value)) { + return 0; + } + + const scale = PixelRatio.get(); + return Math.round(value * scale) / scale; +} + +/** + * Computes the content-box size for a given element width and height. + * + * In React Native, elements do not have CSS padding or border in the + * traditional web sense — the layout dimensions from Yoga already + * represent the content area. This helper applies the fractional pixel + * rounding fix and returns the content-box dimensions. + */ +export function computeContentBoxSize( + width: number, + height: number, +): BoxSize { + return { + inlineSize: roundToDevicePixel(width), + blockSize: roundToDevicePixel(height), + }; +} + +/** + * Computes the border-box size for a given element width and height. + * + * In React Native, Yoga layout dimensions include padding and border, + * so the border-box size is equivalent to the raw layout dimensions + * (after rounding). + */ +export function computeBorderBoxSize( + width: number, + height: number, +): BoxSize { + return { + inlineSize: roundToDevicePixel(width), + blockSize: roundToDevicePixel(height), + }; +} + +/** + * Computes the device-pixel-content-box size for a given element. + * + * This returns the content dimensions in actual device pixels rather + * than density-independent pixels (DIPs). Useful for canvas rendering + * or any scenario requiring exact pixel-level dimensions. + * + * Null-check: If width or height is null/undefined (e.g., for a + * disconnected element), returns zero dimensions to prevent crashes. + */ +export function computeDevicePixelContentBoxSize( + width: number, + height: number, +): BoxSize { + if (width == null || height == null) { + return { + inlineSize: 0, + blockSize: 0, + }; + } + + const scale = PixelRatio.get(); + return { + inlineSize: Math.round(width * scale), + blockSize: Math.round(height * scale), + }; +} diff --git a/packages/react-native/src/private/webapis/dom/observers/__tests__/ResizeObserver-test.js b/packages/react-native/src/private/webapis/dom/observers/__tests__/ResizeObserver-test.js new file mode 100644 index 000000000000..805e2a4cc907 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/observers/__tests__/ResizeObserver-test.js @@ -0,0 +1,317 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import ResizeObserver, {ResizeObserverEntry} from '../ResizeObserver'; +import { + roundToDevicePixel, + computeContentBoxSize, + computeBorderBoxSize, + computeDevicePixelContentBoxSize, +} from '../ResizeObserverUtils'; + +describe('ResizeObserver', () => { + describe('constructor(callback)', () => { + it('should throw if callback is not provided', () => { + expect(() => { + // $FlowExpectedError[incompatible-type] + return new ResizeObserver(); + }).toThrow( + "Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.", + ); + }); + + it('should throw if callback is not a function', () => { + expect(() => { + // $FlowExpectedError[incompatible-type] + return new ResizeObserver('not a function'); + }).toThrow( + "Failed to construct 'ResizeObserver': parameter 1 is not of type 'Function'.", + ); + }); + + it('should throw if callback is null', () => { + expect(() => { + // $FlowExpectedError[incompatible-type] + return new ResizeObserver(null); + }).toThrow( + "Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.", + ); + }); + + it('should create an observer with a valid callback', () => { + const observer = new ResizeObserver(() => {}); + expect(observer).toBeInstanceOf(ResizeObserver); + }); + }); + + describe('observe(target, options)', () => { + it('should throw if target is null', () => { + const observer = new ResizeObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.observe(null); + }).toThrow( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + }); + + it('should throw if target is undefined', () => { + const observer = new ResizeObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.observe(undefined); + }).toThrow( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + }); + + it('should throw for invalid box option', () => { + const observer = new ResizeObserver(() => {}); + const mockTarget = createMockTarget(100, 50); + expect(() => { + // $FlowExpectedError[incompatible-call] + observer.observe(mockTarget, {box: 'invalid-box'}); + }).toThrow("is not a valid enum value of type ResizeObserverBoxOptions"); + }); + + it('should accept valid box options', () => { + const observer = new ResizeObserver(() => {}); + const target1 = createMockTarget(100, 50); + const target2 = createMockTarget(200, 100); + const target3 = createMockTarget(300, 150); + + expect(() => { + observer.observe(target1, {box: 'content-box'}); + observer.observe(target2, {box: 'border-box'}); + observer.observe(target3, {box: 'device-pixel-content-box'}); + }).not.toThrow(); + }); + + it('should default to content-box when no options provided', () => { + const observer = new ResizeObserver(() => {}); + const target = createMockTarget(100, 50); + + expect(() => { + observer.observe(target); + }).not.toThrow(); + }); + }); + + describe('unobserve(target)', () => { + it('should throw if target is null', () => { + const observer = new ResizeObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.unobserve(null); + }).toThrow( + "Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + }); + + it('should not throw when unobserving a target that was never observed', () => { + const observer = new ResizeObserver(() => {}); + const target = createMockTarget(100, 50); + + expect(() => { + observer.unobserve(target); + }).not.toThrow(); + }); + + it('should remove the target from observations', () => { + const observer = new ResizeObserver(() => {}); + const target = createMockTarget(100, 50); + + observer.observe(target); + expect(observer._observations.length).toBe(1); + + observer.unobserve(target); + expect(observer._observations.length).toBe(0); + }); + }); + + describe('disconnect()', () => { + it('should clear all observations', () => { + const observer = new ResizeObserver(() => {}); + const target1 = createMockTarget(100, 50); + const target2 = createMockTarget(200, 100); + + observer.observe(target1); + observer.observe(target2); + expect(observer._observations.length).toBe(2); + + observer.disconnect(); + expect(observer._observations.length).toBe(0); + }); + + it('should allow re-observation after disconnect', () => { + const observer = new ResizeObserver(() => {}); + const target = createMockTarget(100, 50); + + observer.observe(target); + observer.disconnect(); + expect(observer._observations.length).toBe(0); + + observer.observe(target); + expect(observer._observations.length).toBe(1); + }); + }); + + describe('multiple element observation', () => { + it('should track multiple targets independently', () => { + const observer = new ResizeObserver(() => {}); + const target1 = createMockTarget(100, 50); + const target2 = createMockTarget(200, 100); + const target3 = createMockTarget(300, 150); + + observer.observe(target1); + observer.observe(target2); + observer.observe(target3); + expect(observer._observations.length).toBe(3); + + observer.unobserve(target2); + expect(observer._observations.length).toBe(2); + expect(observer._observations[0].target).toBe(target1); + expect(observer._observations[1].target).toBe(target3); + }); + + it('should update box option when observing an already-observed target', () => { + const observer = new ResizeObserver(() => {}); + const target = createMockTarget(100, 50); + + observer.observe(target, {box: 'content-box'}); + expect(observer._observations[0].box).toBe('content-box'); + + observer.observe(target, {box: 'border-box'}); + expect(observer._observations.length).toBe(1); + expect(observer._observations[0].box).toBe('border-box'); + }); + }); + + describe('error handling in callbacks', () => { + it('should not throw when callback is valid', () => { + const callback = jest.fn(); + const observer = new ResizeObserver(callback); + expect(observer).toBeInstanceOf(ResizeObserver); + }); + }); +}); + +describe('ResizeObserverEntry', () => { + it('should expose target', () => { + const target = createMockTarget(100, 50); + const entry = new ResizeObserverEntry(target); + expect(entry.target).toBe(target); + }); + + it('should expose contentRect dimensions', () => { + const target = createMockTarget(100, 50); + const entry = new ResizeObserverEntry(target); + expect(entry.contentRect.width).toBe(100); + expect(entry.contentRect.height).toBe(50); + expect(entry.contentRect.x).toBe(0); + expect(entry.contentRect.y).toBe(0); + }); + + it('should expose contentBoxSize', () => { + const target = createMockTarget(100, 50); + const entry = new ResizeObserverEntry(target); + expect(entry.contentBoxSize).toHaveLength(1); + expect(entry.contentBoxSize[0].inlineSize).toBe(100); + expect(entry.contentBoxSize[0].blockSize).toBe(50); + }); + + it('should expose borderBoxSize', () => { + const target = createMockTarget(100, 50); + const entry = new ResizeObserverEntry(target); + expect(entry.borderBoxSize).toHaveLength(1); + expect(entry.borderBoxSize[0].inlineSize).toBe(100); + expect(entry.borderBoxSize[0].blockSize).toBe(50); + }); + + it('should handle zero dimensions', () => { + const target = createMockTarget(0, 0); + const entry = new ResizeObserverEntry(target); + expect(entry.contentRect.width).toBe(0); + expect(entry.contentRect.height).toBe(0); + }); + + it('should handle null layout gracefully', () => { + const target = createMockTarget(null, null); + const entry = new ResizeObserverEntry(target); + expect(entry.contentRect.width).toBe(0); + expect(entry.contentRect.height).toBe(0); + }); +}); + +describe('ResizeObserverUtils', () => { + describe('roundToDevicePixel', () => { + it('should return 0 for null or undefined', () => { + // $FlowExpectedError[incompatible-call] + expect(roundToDevicePixel(null)).toBe(0); + // $FlowExpectedError[incompatible-call] + expect(roundToDevicePixel(undefined)).toBe(0); + }); + + it('should return 0 for non-finite values', () => { + expect(roundToDevicePixel(Infinity)).toBe(0); + expect(roundToDevicePixel(-Infinity)).toBe(0); + expect(roundToDevicePixel(NaN)).toBe(0); + }); + + it('should round to device pixel', () => { + const result = roundToDevicePixel(100); + expect(typeof result).toBe('number'); + expect(result).toBe(100); + }); + }); + + describe('computeContentBoxSize', () => { + it('should return correct dimensions', () => { + const size = computeContentBoxSize(200, 100); + expect(size.inlineSize).toBe(200); + expect(size.blockSize).toBe(100); + }); + }); + + describe('computeBorderBoxSize', () => { + it('should return correct dimensions', () => { + const size = computeBorderBoxSize(200, 100); + expect(size.inlineSize).toBe(200); + expect(size.blockSize).toBe(100); + }); + }); + + describe('computeDevicePixelContentBoxSize', () => { + it('should return zero for null values', () => { + // $FlowExpectedError[incompatible-call] + const size = computeDevicePixelContentBoxSize(null, null); + expect(size.inlineSize).toBe(0); + expect(size.blockSize).toBe(0); + }); + + it('should return device pixel values', () => { + const size = computeDevicePixelContentBoxSize(100, 50); + expect(typeof size.inlineSize).toBe('number'); + expect(typeof size.blockSize).toBe('number'); + }); + }); +}); + +// Helper to create a mock target that mimics ReactNativeElement with _layout +function createMockTarget( + width: ?number, + height: ?number, +): ReactNativeElement { + // $FlowExpectedError[incompatible-return] - mock for testing + return { + _layout: + width != null && height != null ? {width, height} : null, + }; +}