diff --git a/packages/react-native-fantom/src/index.js b/packages/react-native-fantom/src/index.js index ed8d85582be..2d88c27ae0e 100644 --- a/packages/react-native-fantom/src/index.js +++ b/packages/react-native-fantom/src/index.js @@ -566,6 +566,20 @@ if (typeof global.EventTarget === 'undefined') { ); } +/** + * Returns a function that returns the current reference count for the supplied + * element's shadow node. If the reference count is zero, that means the shadow + * node has been deallocated. + * + * @param node The node for which to create a reference counting function. + */ +export function createShadowNodeReferenceCounter( + node: ReactNativeElement, +): () => number { + let shadowNode = getNativeNodeReference(node); + return NativeFantom.createShadowNodeReferenceCounter(shadowNode); +} + /** * Saves a heap snapshot after forcing garbage collection. * diff --git a/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js b/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js new file mode 100644 index 00000000000..a2e29e5d5b4 --- /dev/null +++ b/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js @@ -0,0 +1,61 @@ +/** + * 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 ReactNativeElement from '../../webapis/dom/nodes/ReadOnlyNode'; +import ensureInstance from './ensureInstance'; +import * as Fantom from '@react-native/fantom'; + +export function createShadowNodeReferenceCounter( + element: ReactNativeElement, +): () => number { + const getReferenceCount = Fantom.createShadowNodeReferenceCounter(element); + // Create the reference counting function in a helper instead of creating a + // closure here, which would unintentionally retain a reference to `element`. + return createExpirationChecker(getReferenceCount); +} + +function createExpirationChecker( + getReferenceCount: () => number, +): () => number { + return () => { + Fantom.runTask(() => { + global.gc(); + }); + return getReferenceCount(); + }; +} + +export function createShadowNodeReferenceCountingRef(): [ + () => number, + React.RefSetter, +] { + let getReferenceCount: ?() => number; + + function getShadowNodeReferenceCount() { + if (getReferenceCount == null) { + throw new Error('ShadowNode reference counter was not initialized.'); + } + return getReferenceCount(); + } + + function ref(instance: mixed | null) { + if (instance == null) { + return; + } + const element = ensureInstance(instance, ReactNativeElement); + if (getReferenceCount != null) { + throw new Error('ShadowNode reference counter was already initialized.'); + } + getReferenceCount = createShadowNodeReferenceCounter(element); + } + + return [getShadowNodeReferenceCount, ref]; +} diff --git a/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js b/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js new file mode 100644 index 00000000000..6803f09df84 --- /dev/null +++ b/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js @@ -0,0 +1,210 @@ +/** + * 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 'react-native/Libraries/Core/InitializeCore'; + +import type {Node} from '../../../../../Libraries/Renderer/shims/ReactNativeTypes'; + +import {getNodeFromPublicInstance} from '../../../../../Libraries/ReactPrivate/ReactNativePrivateInterface'; +import ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; +import ensureInstance from '../ensureInstance'; +import isUnreachable from '../isUnreachable'; +import { + createShadowNodeReferenceCounter, + createShadowNodeReferenceCountingRef, +} from '../ShadowNodeReferenceCounter'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {View} from 'react-native'; + +test('shadow node expires when root is destroyed', () => { + const root = Fantom.createRoot(); + + const [getReferenceCount, ref] = createShadowNodeReferenceCountingRef(); + + Fantom.runTask(() => { + root.render(); + }); + + expect(getReferenceCount()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.destroy(); + }); + + expect(getReferenceCount()).toBe(0); +}); + +test('element is not retained by `createShadowNodeReferenceCounter`', () => { + const root = Fantom.createRoot(); + + let elementWeakRef: ?WeakRef; + let getReferenceCount: ?() => number; + + function ref(instance: React.ElementRef | null) { + if (instance == null) { + return; + } + const element = ensureInstance(instance, ReactNativeElement); + elementWeakRef = new WeakRef(element); + getReferenceCount = createShadowNodeReferenceCounter(element); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(isUnreachable(nullthrows(elementWeakRef))).toBe(false); + expect(getReferenceCount?.()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.destroy(); + }); + + expect(isUnreachable(nullthrows(elementWeakRef))).toBe(true); + expect(getReferenceCount?.()).toBe(0); +}); + +test('shadow node expires when JavaScript element is reachable', () => { + const root = Fantom.createRoot(); + + let element: ?ReactNativeElement; + let getReferenceCount: ?() => number; + + function ref(instance: React.ElementRef | null) { + if (instance == null) { + return; + } + element = ensureInstance(instance, ReactNativeElement); + getReferenceCount = createShadowNodeReferenceCounter(element); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(element).not.toBe(undefined); + expect(getNodeFromPublicInstance(nullthrows(element))).not.toBe(null); + expect(getReferenceCount?.()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.destroy(); + }); + + expect(element).not.toBe(undefined); + expect(getNodeFromPublicInstance(nullthrows(element))).toBe(null); + expect(getReferenceCount?.()).toBe(0); +}); + +test('shadow node is retained when JavaScript node is reachable', () => { + const root = Fantom.createRoot(); + + let node: ?Node; + let getReferenceCount: ?() => number; + + function ref(instance: React.ElementRef | null) { + if (instance == null) { + return; + } + const element = ensureInstance(instance, ReactNativeElement); + node = getNodeFromPublicInstance(element); + getReferenceCount = createShadowNodeReferenceCounter(element); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(node).not.toBe(undefined); + expect(getReferenceCount?.()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.destroy(); + }); + + expect(node).not.toBe(undefined); + expect(getReferenceCount?.()).toBeGreaterThan(0); +}); + +test('shadow node expires when replaced by null', () => { + const root = Fantom.createRoot(); + + const [getReferenceCount, ref] = createShadowNodeReferenceCountingRef(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(getReferenceCount()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.render({null}); + }); + + // TODO (T223254666): Delete this and figure out why test fails. + Fantom.runTask(() => { + root.render({null}); + }); + + expect(getReferenceCount()).toBe(0); +}); + +test('shadow node expires when replaced by another view', () => { + const root = Fantom.createRoot(); + + const [getReferenceCount, ref] = createShadowNodeReferenceCountingRef(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(getReferenceCount()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + // TODO (T223254666): Delete this and figure out why test fails. + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(getReferenceCount()).toBe(0); +}); diff --git a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js index 786f77d1267..c3c5a500aa0 100644 --- a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js +++ b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js @@ -93,6 +93,9 @@ interface Spec extends TurboModule { validateEmptyMessageQueue: () => void; getRenderedOutput: (surfaceId: number, config: RenderFormatOptions) => string; reportTestSuiteResultsJSON: (results: string) => void; + createShadowNodeReferenceCounter( + shadowNode: mixed /* ShadowNode */, + ): () => number; saveJSMemoryHeapSnapshot: (filePath: string) => void; }