From 9090712fd3ca4e1099e1f92e67933c2cb4f32552 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 18 Apr 2024 13:37:54 -0400 Subject: [PATCH] Support writing to this.refs from userspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the `refs` property of a class component instance was read-only by user code — only React could write to it, and until/unless a string ref was used, it pointed to a shared empty object that was frozen in dev to prevent userspace mutations. Because string refs are deprecated, we want users to be able to codemod all their string refs to callback refs. The safest way to do this is to output a callback ref that assigns to `this.refs`. So to support this, we need to make `this.refs` writable by userspace. --- .../src/ReactChildFiber.new.js | 7 +- .../src/ReactChildFiber.old.js | 7 +- .../src/ReactFiberClassComponent.new.js | 7 +- .../src/ReactFiberClassComponent.old.js | 7 +- .../src/__tests__/ReactFiberRefs-test.js | 74 +++++++++++++++++++ 5 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactFiberRefs-test.js diff --git a/packages/react-reconciler/src/ReactChildFiber.new.js b/packages/react-reconciler/src/ReactChildFiber.new.js index efdad894e6..4c7a9d62c4 100644 --- a/packages/react-reconciler/src/ReactChildFiber.new.js +++ b/packages/react-reconciler/src/ReactChildFiber.new.js @@ -34,7 +34,6 @@ import { createFiberFromText, createFiberFromPortal, } from './ReactFiber.new'; -import {emptyRefsObject} from './ReactFiberClassComponent.new'; import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.new'; import {StrictLegacyMode} from './ReactTypeOfMode'; import {getIsHydrating} from './ReactFiberHydrationContext.new'; @@ -199,11 +198,7 @@ function coerceRef( return current.ref; } const ref = function(value) { - let refs = resolvedInst.refs; - if (refs === emptyRefsObject) { - // This is a lazy pooled frozen object, so we need to initialize. - refs = resolvedInst.refs = {}; - } + const refs = resolvedInst.refs; if (value === null) { delete refs[stringRef]; } else { diff --git a/packages/react-reconciler/src/ReactChildFiber.old.js b/packages/react-reconciler/src/ReactChildFiber.old.js index 6f20996707..9b2ba7706e 100644 --- a/packages/react-reconciler/src/ReactChildFiber.old.js +++ b/packages/react-reconciler/src/ReactChildFiber.old.js @@ -34,7 +34,6 @@ import { createFiberFromText, createFiberFromPortal, } from './ReactFiber.old'; -import {emptyRefsObject} from './ReactFiberClassComponent.old'; import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.old'; import {StrictLegacyMode} from './ReactTypeOfMode'; import {getIsHydrating} from './ReactFiberHydrationContext.old'; @@ -199,11 +198,7 @@ function coerceRef( return current.ref; } const ref = function(value) { - let refs = resolvedInst.refs; - if (refs === emptyRefsObject) { - // This is a lazy pooled frozen object, so we need to initialize. - refs = resolvedInst.refs = {}; - } + const refs = resolvedInst.refs; if (value === null) { delete refs[stringRef]; } else { diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.new.js b/packages/react-reconciler/src/ReactFiberClassComponent.new.js index fe1aea87f0..e728ca9220 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.new.js @@ -12,7 +12,6 @@ import type {Lanes} from './ReactFiberLane.new'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {Flags} from './ReactFiberFlags'; -import * as React from 'react'; import { LayoutStatic, MountLayoutDev, @@ -82,10 +81,6 @@ import { const fakeInternalInstance = {}; -// React.Component uses a shared frozen object by default. -// We'll use it to determine whether we need to initialize legacy refs. -export const emptyRefsObject = new React.Component().refs; - let didWarnAboutStateAssignmentForComponent; let didWarnAboutUninitializedState; let didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate; @@ -871,7 +866,7 @@ function mountClassInstance( const instance = workInProgress.stateNode; instance.props = newProps; instance.state = workInProgress.memoizedState; - instance.refs = emptyRefsObject; + instance.refs = {}; initializeUpdateQueue(workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.old.js b/packages/react-reconciler/src/ReactFiberClassComponent.old.js index b4ad1602da..9b0f297cc1 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.old.js @@ -12,7 +12,6 @@ import type {Lanes} from './ReactFiberLane.old'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {Flags} from './ReactFiberFlags'; -import * as React from 'react'; import { LayoutStatic, MountLayoutDev, @@ -82,10 +81,6 @@ import { const fakeInternalInstance = {}; -// React.Component uses a shared frozen object by default. -// We'll use it to determine whether we need to initialize legacy refs. -export const emptyRefsObject = new React.Component().refs; - let didWarnAboutStateAssignmentForComponent; let didWarnAboutUninitializedState; let didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate; @@ -871,7 +866,7 @@ function mountClassInstance( const instance = workInProgress.stateNode; instance.props = newProps; instance.state = workInProgress.memoizedState; - instance.refs = emptyRefsObject; + instance.refs = {}; initializeUpdateQueue(workInProgress); diff --git a/packages/react-reconciler/src/__tests__/ReactFiberRefs-test.js b/packages/react-reconciler/src/__tests__/ReactFiberRefs-test.js new file mode 100644 index 0000000000..db07afd863 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactFiberRefs-test.js @@ -0,0 +1,74 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactNoop; +let act; + +describe('ReactFiberRefs', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactNoop = require('react-noop-renderer'); + act = require('jest-react').act; + }); + + test('strings refs can be codemodded to callback refs', async () => { + let app; + class App extends React.Component { + render() { + app = this; + return ( +
{ + // `refs` used to be a shared frozen object unless/until a string + // ref attached by the reconciler, but it's not anymore so that we + // can codemod string refs to userspace callback refs. + this.refs.div = el; + }} + /> + ); + } + } + + const root = ReactNoop.createRoot(); + await act(async () => root.render()); + expect(app.refs.div.prop).toBe('Hello!'); + }); + + test('class refs are initialized to a frozen shared object', async () => { + const refsCollection = new Set(); + class Component extends React.Component { + constructor(props) { + super(props); + refsCollection.add(this.refs); + } + render() { + return
; + } + } + + const root = ReactNoop.createRoot(); + await act(() => + root.render( + <> + + + , + ), + ); + + expect(refsCollection.size).toBe(1); + const refsInstance = Array.from(refsCollection)[0]; + expect(Object.isFrozen(refsInstance)).toBe(__DEV__); + }); +});