diff --git a/packages/react-native/Libraries/DOM/Nodes/ReadOnlyCharacterData.js b/packages/react-native/Libraries/DOM/Nodes/ReadOnlyCharacterData.js new file mode 100644 index 00000000000..6f3fb0f4ad7 --- /dev/null +++ b/packages/react-native/Libraries/DOM/Nodes/ReadOnlyCharacterData.js @@ -0,0 +1,72 @@ +/** + * 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. + * + * @format + * @flow strict-local + */ + +// flowlint unsafe-getters-setters:off + +import type ReadOnlyElement from './ReadOnlyElement'; + +import {getFabricUIManager} from '../../ReactNative/FabricUIManager'; +import ReadOnlyNode, {getShadowNode} from './ReadOnlyNode'; +import {getElementSibling} from './Utilities/Traversal'; +import nullthrows from 'nullthrows'; + +export default class ReadOnlyCharacterData extends ReadOnlyNode { + get nextElementSibling(): ReadOnlyElement | null { + return getElementSibling(this, 'next'); + } + + get previousElementSibling(): ReadOnlyElement | null { + return getElementSibling(this, 'previous'); + } + + get data(): string { + const shadowNode = getShadowNode(this); + + if (shadowNode != null) { + return nullthrows(getFabricUIManager()).getTextContent(shadowNode); + } + + return ''; + } + + get length(): number { + return this.data.length; + } + + /** + * @override + */ + get textContent(): string | null { + return this.data; + } + + /** + * @override + */ + get nodeValue(): string { + return this.data; + } + + substringData(offset: number, count: number): string { + const data = this.data; + if (offset < 0) { + throw new TypeError( + `Failed to execute 'substringData' on 'CharacterData': The offset ${offset} is negative.`, + ); + } + if (offset > data.length) { + throw new TypeError( + `Failed to execute 'substringData' on 'CharacterData': The offset ${offset} is greater than the node's length (${data.length}).`, + ); + } + let adjustedCount = count < 0 || count > data.length ? data.length : count; + return data.substr(offset, adjustedCount); + } +} diff --git a/packages/react-native/Libraries/DOM/Nodes/ReadOnlyElement.js b/packages/react-native/Libraries/DOM/Nodes/ReadOnlyElement.js index ca60bdf1149..163deca45fd 100644 --- a/packages/react-native/Libraries/DOM/Nodes/ReadOnlyElement.js +++ b/packages/react-native/Libraries/DOM/Nodes/ReadOnlyElement.js @@ -16,6 +16,7 @@ import {getFabricUIManager} from '../../ReactNative/FabricUIManager'; import DOMRect from '../Geometry/DOMRect'; import {createHTMLCollection} from '../OldStyleCollections/HTMLCollection'; import ReadOnlyNode, {getChildNodes, getShadowNode} from './ReadOnlyNode'; +import {getElementSibling} from './Utilities/Traversal'; import nullthrows from 'nullthrows'; export default class ReadOnlyElement extends ReadOnlyNode { @@ -68,14 +69,7 @@ export default class ReadOnlyElement extends ReadOnlyNode { } get nextElementSibling(): ReadOnlyElement | null { - const [siblings, position] = getElementSiblingsAndPosition(this); - - if (position === siblings.length - 1) { - // this node is the last child of its parent, so there is no next sibling. - return null; - } - - return siblings[position + 1]; + return getElementSibling(this, 'next'); } get nodeName(): string { @@ -93,14 +87,7 @@ export default class ReadOnlyElement extends ReadOnlyNode { set nodeValue(value: string): void {} get previousElementSibling(): ReadOnlyElement | null { - const [siblings, position] = getElementSiblingsAndPosition(this); - - if (position === 0) { - // this node is the last child of its parent, so there is no next sibling. - return null; - } - - return siblings[position - 1]; + return getElementSibling(this, 'previous'); } get scrollHeight(): number { @@ -161,22 +148,3 @@ function getChildElements(node: ReadOnlyNode): $ReadOnlyArray { childNode => childNode instanceof ReadOnlyElement, ); } - -export function getElementSiblingsAndPosition( - element: ReadOnlyElement, -): [$ReadOnlyArray, number] { - const parent = element.parentNode; - if (parent == null) { - // This node is the root or it's disconnected. - return [[element], 0]; - } - - const siblings = getChildElements(parent); - const position = siblings.indexOf(element); - - if (position === -1) { - throw new TypeError("Missing node in parent's child node list"); - } - - return [siblings, position]; -} diff --git a/packages/react-native/Libraries/DOM/Nodes/ReadOnlyText.js b/packages/react-native/Libraries/DOM/Nodes/ReadOnlyText.js new file mode 100644 index 00000000000..91131f7298b --- /dev/null +++ b/packages/react-native/Libraries/DOM/Nodes/ReadOnlyText.js @@ -0,0 +1,30 @@ +/** + * 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. + * + * @format + * @flow strict-local + */ + +// flowlint unsafe-getters-setters:off + +import ReadOnlyCharacterData from './ReadOnlyCharacterData'; +import ReadOnlyNode from './ReadOnlyNode'; + +export default class ReadOnlyText extends ReadOnlyCharacterData { + /** + * @override + */ + get nodeName(): string { + return '#text'; + } + + /** + * @override + */ + get nodeType(): number { + return ReadOnlyNode.TEXT_NODE; + } +} diff --git a/packages/react-native/Libraries/DOM/Nodes/Utilities/Traversal.js b/packages/react-native/Libraries/DOM/Nodes/Utilities/Traversal.js new file mode 100644 index 00000000000..083d6bfafe7 --- /dev/null +++ b/packages/react-native/Libraries/DOM/Nodes/Utilities/Traversal.js @@ -0,0 +1,54 @@ +/** + * 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. + * + * @format + * @flow strict-local + */ + +import type ReadOnlyElement from '../ReadOnlyElement'; +import type ReadOnlyNode from '../ReadOnlyNode'; + +import {getChildNodes} from '../ReadOnlyNode'; + +// We initialize this lazily to avoid a require cycle +// (`ReadOnlyElement` also depends on `Traversal`). +let ReadOnlyElementClass: Class; + +export function getElementSibling( + node: ReadOnlyNode, + direction: 'next' | 'previous', +): ReadOnlyElement | null { + const parent = node.parentNode; + if (parent == null) { + // This node is the root or it's disconnected. + return null; + } + + const childNodes = getChildNodes(parent); + + const startPosition = childNodes.indexOf(node); + if (startPosition === -1) { + return null; + } + + const increment = direction === 'next' ? 1 : -1; + + let position = startPosition + increment; + + if (ReadOnlyElementClass == null) { + // We initialize this lazily to avoid a require cycle. + ReadOnlyElementClass = require('../ReadOnlyElement').default; + } + + while ( + childNodes[position] != null && + !(childNodes[position] instanceof ReadOnlyElementClass) + ) { + position = position + increment; + } + + return childNodes[position] ?? null; +} diff --git a/packages/react-native/Libraries/DOM/Nodes/__tests__/ReadOnlyText-test.js b/packages/react-native/Libraries/DOM/Nodes/__tests__/ReadOnlyText-test.js new file mode 100644 index 00000000000..4dee9d6b33f --- /dev/null +++ b/packages/react-native/Libraries/DOM/Nodes/__tests__/ReadOnlyText-test.js @@ -0,0 +1,407 @@ +/** + * 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 + * @oncall react_native + */ + +import type ReactNativeElement from '../ReactNativeElement'; + +import invariant from 'invariant'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {act} from 'react-test-renderer'; + +jest.mock('../../../ReactNative/FabricUIManager', () => + require('../../../ReactNative/__mocks__/FabricUIManager'), +); + +const MOCK_CONTAINER_TAG = 11; + +describe('ReadOnlyText', () => { + let ReactFabric; + let NativeText; + let ReadOnlyText; + let ReadOnlyNode; + + beforeEach(() => { + jest.resetModules(); + + // Installs the global `nativeFabricUIManager` pointing to the mock. + require('../../../ReactNative/__mocks__/FabricUIManager'); + require('../../../ReactNative/ReactNativeFeatureFlags').enableAccessToHostTreeInFabric = + () => true; + + ReactFabric = require('../../../Renderer/shims/ReactFabric'); + NativeText = require('../../../Text/TextNativeComponent').NativeText; + ReadOnlyText = require('../ReadOnlyText').default; + ReadOnlyNode = require('../ReadOnlyNode').default; + }); + + it('should be used to create public text instances when the `enableAccessToHostTreeInFabric` feature flag is enabled', () => { + let lastParentNode; + + act(() => { + ReactFabric.render( + { + lastParentNode = node; + }}> + Some text + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentNode: ReadOnlyNode = nullthrows(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode).toBeInstanceOf(ReadOnlyText); + }); + + describe('extends `ReadOnlyNode`', () => { + describe('nodeName', () => { + it('returns "#text"', () => { + let lastParentNode; + + act(() => { + ReactFabric.render( + { + lastParentNode = node; + }}> + Some text + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentNode: ReadOnlyNode = nullthrows(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode.nodeName).toBe('#text'); + }); + }); + + describe('nodeType', () => { + it('returns ReadOnlyNode.TEXT_NODE', () => { + let lastParentNode; + + act(() => { + ReactFabric.render( + { + lastParentNode = node; + }}> + Some text + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentNode: ReadOnlyNode = nullthrows(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode.nodeType).toBe(ReadOnlyNode.TEXT_NODE); + }); + }); + + describe('nodeValue / textContent', () => { + it('returns the string data contained in the node', () => { + let lastParentNode; + + act(() => { + ReactFabric.render( + { + lastParentNode = node; + }}> + Some text + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentNode: ReadOnlyNode = nullthrows(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode.nodeValue).toBe('Some text'); + expect(textNode.textContent).toBe('Some text'); + }); + }); + + describe('traversal', () => { + it('only preserves text nodes when their contents do not change', () => { + let lastParentElement; + let lastChildElementA; + + act(() => { + ReactFabric.render( + { + lastParentElement = element; + }}> + Text A + { + lastChildElementA = element; + }} + /> + Text B + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentElement: ReactNativeElement = nullthrows(lastParentElement); + // $FlowExpectedError[incompatible-type] + const childElementA: ReactNativeElement = nullthrows(lastChildElementA); + + // Get text nodes and refine them as text nodes for Flow + const childTextA = parentElement.childNodes[0]; + invariant( + childTextA instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + expect(childTextA.textContent).toBe('Text A'); + const childTextB = parentElement.childNodes[2]; + invariant( + childTextB instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + expect(childTextB.textContent).toBe('Text B'); + + // Validate structure + expect(parentElement.childNodes.length).toBe(3); + expect(parentElement.childNodes[0]).toBe(childTextA); + expect(parentElement.childNodes[1]).toBe(childElementA); + expect(parentElement.childNodes[2]).toBe(childTextB); + + // Change contents of the second text only + act(() => { + ReactFabric.render( + { + lastParentElement = element; + }}> + Text A + { + lastChildElementA = element; + }} + /> + Text B modified + , + MOCK_CONTAINER_TAG, + ); + }); + + expect(parentElement.childNodes.length).toBe(3); + expect(parentElement.childNodes[0]).toBe(childTextA); + expect(parentElement.childNodes[1]).toBe(childElementA); + expect(parentElement.childNodes[2]).not.toBe(childTextB); + expect(parentElement.childNodes[2]).toBeInstanceOf(ReadOnlyText); + expect(parentElement.childNodes[2]).toMatchObject({ + data: 'Text B modified', + }); + expect(childTextB.isConnected).toBe(false); + }); + }); + }); + + describe('extends `ReadOnlyCharacterData`', () => { + describe('data / length', () => { + it('returns the string data and its length, respectively', () => { + let lastParentNode; + + act(() => { + ReactFabric.render( + { + lastParentNode = node; + }}> + Some text + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentNode: ReadOnlyNode = nullthrows(lastParentNode); + // $FlowExpectedError[incompatible-type] + const textNode: ReadOnlyText = parentNode.childNodes[0]; + + expect(textNode.data).toBe('Some text'); + expect(textNode.length).toBe('Some text'.length); + }); + }); + + describe('previousElementSibling / nextElementSibling', () => { + it('return updated relative elements', () => { + let lastParentElement; + let lastChildElementA; + let lastChildElementB; + let lastChildElementC; + + act(() => { + ReactFabric.render( + { + lastParentElement = element; + }}> + Text A + { + lastChildElementA = element; + }} + /> + Text B + { + lastChildElementB = element; + }} + /> + Text C + { + lastChildElementC = element; + }} + /> + Text D + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentElement: ReactNativeElement = nullthrows(lastParentElement); + // $FlowExpectedError[incompatible-type] + const childElementA: ReactNativeElement = nullthrows(lastChildElementA); + // $FlowExpectedError[incompatible-type] + const childElementB: ReactNativeElement = nullthrows(lastChildElementB); + // $FlowExpectedError[incompatible-type] + const childElementC: ReactNativeElement = nullthrows(lastChildElementC); + + // Get text nodes and refine them as text nodes for Flow + const childTextA = parentElement.childNodes[0]; + invariant( + childTextA instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + const childTextB = parentElement.childNodes[2]; + invariant( + childTextB instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + const childTextC = parentElement.childNodes[4]; + invariant( + childTextC instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + const childTextD = parentElement.childNodes[6]; + invariant( + childTextD instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + + // Validate structure + expect(parentElement.childNodes.length).toBe(7); + expect(parentElement.childNodes[0]).toBe(childTextA); + expect(parentElement.childNodes[1]).toBe(childElementA); + expect(parentElement.childNodes[2]).toBe(childTextB); + expect(parentElement.childNodes[3]).toBe(childElementB); + expect(parentElement.childNodes[4]).toBe(childTextC); + expect(parentElement.childNodes[5]).toBe(childElementC); + expect(parentElement.childNodes[6]).toBe(childTextD); + + expect(childTextA.previousElementSibling).toBe(null); + expect(childTextA.nextElementSibling).toBe(childElementA); + + expect(childTextB.previousElementSibling).toBe(childElementA); + expect(childTextB.nextElementSibling).toBe(childElementB); + + expect(childTextC.previousElementSibling).toBe(childElementB); + expect(childTextC.nextElementSibling).toBe(childElementC); + + expect(childTextD.previousElementSibling).toBe(childElementC); + expect(childTextD.nextElementSibling).toBe(null); + }); + }); + + describe('substringData', () => { + it('returns a slice of the text content', () => { + let lastParentElement; + + act(() => { + ReactFabric.render( + { + lastParentElement = element; + }}> + Text A + , + MOCK_CONTAINER_TAG, + ); + }); + + // $FlowExpectedError[incompatible-type] + const parentElement: ReactNativeElement = nullthrows(lastParentElement); + + // Get text nodes and refine them as text nodes for Flow + const childTextA = parentElement.childNodes[0]; + invariant( + childTextA instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + + expect(childTextA.substringData(0, 1)).toBe('T'); + expect(childTextA.substringData(0, 5)).toBe('Text '); + // Count > length + expect(childTextA.substringData(0, 10)).toBe('Text A'); + // Count = length + expect(childTextA.substringData(0, 6)).toBe('Text A'); + expect(childTextA.substringData(0, 0)).toBe(''); + // Negative count + expect(childTextA.substringData(0, -1)).toBe('Text A'); + expect(childTextA.substringData(0, -10)).toBe('Text A'); + + expect(childTextA.substringData(1, 3)).toBe('ext'); + expect(childTextA.substringData(5, 1)).toBe('A'); + // Offset + count > length + expect(childTextA.substringData(5, 2)).toBe('A'); + // Offset = length + expect(childTextA.substringData(6, 1)).toBe(''); + // Offset = length & negative count + expect(childTextA.substringData(6, -1)).toBe(''); + // Negative count + expect(childTextA.substringData(5, -1)).toBe('A'); + + // Out of bounds offset + expect(() => { + childTextA.substringData(-1, 0); + }).toThrow(); + expect(() => { + childTextA.substringData(7, 0); + }).toThrow(); + }); + }); + }); +}); diff --git a/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance.js b/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance.js index f8722df1d60..71730351f2a 100644 --- a/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance.js +++ b/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance.js @@ -9,6 +9,7 @@ */ import type ReactNativeElement from '../../DOM/Nodes/ReactNativeElement'; +import type ReadOnlyText from '../../DOM/Nodes/ReadOnlyText'; import typeof ReactFabricType from '../../Renderer/shims/ReactFabric'; import type { InternalInstanceHandle, @@ -23,6 +24,7 @@ import ReactNativeFeatureFlags from '../ReactNativeFeatureFlags'; let PublicInstanceClass: | Class | Class; +let ReadOnlyTextClass: Class; // Lazy loaded to avoid evaluating the module when using the legacy renderer. let ReactFabric: ReactFabricType; @@ -46,11 +48,14 @@ export function createPublicInstance( return new PublicInstanceClass(tag, viewConfig, internalInstanceHandle); } -export function createPublicTextInstance(internalInstanceHandle: mixed): {} { - // React will call this method to create text instances but we'll return an - // empty object for now. These instances are only created lazily when - // traversing the tree, and that's not enabled yet. - return {}; +export function createPublicTextInstance( + internalInstanceHandle: InternalInstanceHandle, +): ReadOnlyText { + if (ReadOnlyTextClass == null) { + ReadOnlyTextClass = require('../../DOM/Nodes/ReadOnlyText').default; + } + + return new ReadOnlyTextClass(internalInstanceHandle); } export function getNativeTagFromPublicInstance( diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index e4398f865c1..885c88da4ab 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -903,6 +903,9 @@ jsi::Value UIManagerBinding::get( // concatenates all the text contents. Otherwise, it returns an empty // string. + // This is also used to access the text content of text nodes, which does + // not need any traversal. + // getTextContent(shadowNode: ShadowNode): string return jsi::Function::createFromHostFunction( runtime,