Implement ReadOnlyText and ReadOnlyCharacterData

Summary:
This adds supports for text instances in React Native using a DOM-like interface (as defined in https://github.com/react-native-community/discussions-and-proposals/pull/607).

This reuses the `getTextContent` method from Fabric that we added in D44464637, which is supported for this use case as well.

Changelog: [internal]

bypass-github-export-checks

Reviewed By: rshest

Differential Revision: D44632362

fbshipit-source-id: edea99ce61fb17d33853c72196ece7bb06a01e41
This commit is contained in:
Rubén Norte
2023-04-13 09:19:00 -07:00
committed by Facebook GitHub Bot
parent 26fdb44c57
commit 6dbc2ccdf9
7 changed files with 579 additions and 40 deletions
@@ -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);
}
}
@@ -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<ReadOnlyElement> {
childNode => childNode instanceof ReadOnlyElement,
);
}
export function getElementSiblingsAndPosition(
element: ReadOnlyElement,
): [$ReadOnlyArray<ReadOnlyElement>, 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];
}
@@ -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;
}
}
@@ -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<ReadOnlyElement>;
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;
}
@@ -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(
<NativeText
ref={node => {
lastParentNode = node;
}}>
Some text
</NativeText>,
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(
<NativeText
ref={node => {
lastParentNode = node;
}}>
Some text
</NativeText>,
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(
<NativeText
ref={node => {
lastParentNode = node;
}}>
Some text
</NativeText>,
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(
<NativeText
ref={node => {
lastParentNode = node;
}}>
Some text
</NativeText>,
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(
<NativeText
key="parent"
ref={element => {
lastParentElement = element;
}}>
Text A
<NativeText
key="childA"
ref={element => {
lastChildElementA = element;
}}
/>
Text B
</NativeText>,
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(
<NativeText
key="parent"
ref={element => {
lastParentElement = element;
}}>
Text A
<NativeText
key="childA"
ref={element => {
lastChildElementA = element;
}}
/>
Text B modified
</NativeText>,
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(
<NativeText
ref={node => {
lastParentNode = node;
}}>
Some text
</NativeText>,
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(
<NativeText
key="parent"
ref={element => {
lastParentElement = element;
}}>
Text A
<NativeText
key="childA"
ref={element => {
lastChildElementA = element;
}}
/>
Text B
<NativeText
key="childB"
ref={element => {
lastChildElementB = element;
}}
/>
Text C
<NativeText
key="childC"
ref={element => {
lastChildElementC = element;
}}
/>
Text D
</NativeText>,
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(
<NativeText
key="parent"
ref={element => {
lastParentElement = element;
}}>
Text A
</NativeText>,
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();
});
});
});
});
@@ -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<ReactFabricHostComponent>
| Class<ReactNativeElement>;
let ReadOnlyTextClass: Class<ReadOnlyText>;
// 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(
@@ -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,