diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 1b010e09966d..e0e883c4b21b 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -470,6 +470,28 @@ export function beforeRemoveInstance(instance) { // noop } +export function isOpaqueHydratingObject(value: mixed): boolean { + throw new Error('Not yet implemented'); +} + +export function makeOpaqueHydratingObject( + attemptToReadValue: () => void, +): OpaqueIDType { + throw new Error('Not yet implemented.'); +} + +export function makeClientId(): OpaqueIDType { + throw new Error('Not yet implemented'); +} + +export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { + throw new Error('Not yet implemented'); +} + +export function makeServerId(): OpaqueIDType { + throw new Error('Not yet implemented'); +} + export function registerEvent(event: any, rootContainerInstance: any) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 1fab259eb127..7b34a50394e2 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -18,12 +18,16 @@ import type { ReactScopeMethods, } from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; +import type {OpaqueIDType} from 'react-reconciler/src/ReactFiberHostConfig'; + import type {Hook, TimeoutConfig} from 'react-reconciler/src/ReactFiberHooks'; import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberHooks'; import type {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig'; +import {NoMode} from 'react-reconciler/src/ReactTypeOfMode'; import ErrorStackParser from 'error-stack-parser'; import ReactSharedInternals from 'shared/ReactSharedInternals'; +import {REACT_OPAQUE_ID_TYPE} from 'shared/ReactSymbols'; import { FunctionComponent, SimpleMemoComponent, @@ -61,6 +65,8 @@ type Dispatch = A => void; let primitiveStackCache: null | Map> = null; +let currentFiber: Fiber | null = null; + function getPrimitiveStackCache(): Map> { // This initializes a cache of all primitive hooks so that the top // most stack frames added by calling the primitive hook can be removed. @@ -319,6 +325,23 @@ function useDeferredValue(value: T, config: TimeoutConfig | null | void): T { return value; } +function useOpaqueIdentifier(): OpaqueIDType | void { + const hook = nextHook(); // State + if (currentFiber && currentFiber.mode === NoMode) { + nextHook(); // Effect + } + let value = hook === null ? undefined : hook.memoizedState; + if (value && value.$$typeof === REACT_OPAQUE_ID_TYPE) { + value = undefined; + } + hookLog.push({ + primitive: 'OpaqueIdentifier', + stackError: new Error(), + value, + }); + return value; +} + const Dispatcher: DispatcherType = { readContext, useCallback, @@ -336,6 +359,7 @@ const Dispatcher: DispatcherType = { useMutableSource, useDeferredValue, useEvent, + useOpaqueIdentifier, }; // Inspect @@ -684,6 +708,8 @@ export function inspectHooksOfFiber( currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher; } + currentFiber = fiber; + if ( fiber.tag !== FunctionComponent && fiber.tag !== SimpleMemoComponent && diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index ef41b65426f3..cca7a71dd603 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -422,6 +422,64 @@ describe('ReactHooksInspectionIntegration', () => { }, ]); }); + + it('should support composite useOpaqueIdentifier hook', () => { + function Foo(props) { + const id = React.unstable_useOpaqueIdentifier(); + const [state] = React.useState(() => 'hello', []); + return
{state}
; + } + + const renderer = ReactTestRenderer.create(); + const childFiber = renderer.root.findByType(Foo)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + + expect(tree.length).toEqual(2); + + expect(tree[0].id).toEqual(0); + expect(tree[0].isStateEditable).toEqual(false); + expect(tree[0].name).toEqual('OpaqueIdentifier'); + expect((tree[0].value + '').startsWith('c_')).toBe(true); + + expect(tree[1]).toEqual({ + id: 1, + isStateEditable: true, + name: 'State', + value: 'hello', + subHooks: [], + }); + }); + + it('should support composite useOpaqueIdentifier hook in concurrent mode', () => { + function Foo(props) { + const id = React.unstable_useOpaqueIdentifier(); + const [state] = React.useState(() => 'hello', []); + return
{state}
; + } + + const renderer = ReactTestRenderer.create(, { + unstable_isConcurrent: true, + }); + expect(Scheduler).toFlushWithoutYielding(); + + const childFiber = renderer.root.findByType(Foo)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + + expect(tree.length).toEqual(2); + + expect(tree[0].id).toEqual(0); + expect(tree[0].isStateEditable).toEqual(false); + expect(tree[0].name).toEqual('OpaqueIdentifier'); + expect((tree[0].value + '').startsWith('c_')).toBe(true); + + expect(tree[1]).toEqual({ + id: 1, + isStateEditable: true, + name: 'State', + value: 'hello', + subHooks: [], + }); + }); } describe('useDebugValue', () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js index 012e420f80ec..06fca95894ae 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js @@ -18,6 +18,7 @@ let ReactFeatureFlags; let ReactDOM; let ReactDOMServer; let ReactTestUtils; +let Scheduler; let useState; let useReducer; let useEffect; @@ -28,6 +29,7 @@ let useRef; let useImperativeHandle; let useLayoutEffect; let useDebugValue; +let useOpaqueIdentifier; let forwardRef; let yieldedValues; let yieldValue; @@ -39,10 +41,12 @@ function initModules() { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; React = require('react'); ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); ReactTestUtils = require('react-dom/test-utils'); + Scheduler = require('scheduler'); useState = React.useState; useReducer = React.useReducer; useEffect = React.useEffect; @@ -53,6 +57,7 @@ function initModules() { useDebugValue = React.useDebugValue; useImperativeHandle = React.useImperativeHandle; useLayoutEffect = React.useLayoutEffect; + useOpaqueIdentifier = React.unstable_useOpaqueIdentifier; forwardRef = React.forwardRef; yieldedValues = []; @@ -78,6 +83,9 @@ const { itRenders, itThrowsWhenRendering, serverRender, + streamRender, + clientCleanRender, + clientRenderOnServerString, } = ReactDOMServerIntegrationUtils(initModules); describe('ReactDOMServerHooks', () => { @@ -834,4 +842,902 @@ describe('ReactDOMServerHooks', () => { expect(domNode2.textContent).toEqual('42'); }); }); + + if (__EXPERIMENTAL__) { + describe('useOpaqueIdentifier', () => { + it('generates unique ids for server string render', async () => { + function App(props) { + const idOne = useOpaqueIdentifier(); + const idTwo = useOpaqueIdentifier(); + return ( +
+
+
+ + +
+ ); + } + + const domNode = await serverRender(); + expect(domNode.children.length).toEqual(4); + expect(domNode.children[0].getAttribute('aria-labelledby')).toEqual( + domNode.children[1].getAttribute('id'), + ); + expect(domNode.children[2].getAttribute('aria-labelledby')).toEqual( + domNode.children[3].getAttribute('id'), + ); + expect(domNode.children[0].getAttribute('aria-labelledby')).not.toEqual( + domNode.children[2].getAttribute('aria-labelledby'), + ); + expect( + domNode.children[0].getAttribute('aria-labelledby'), + ).not.toBeNull(); + expect( + domNode.children[2].getAttribute('aria-labelledby'), + ).not.toBeNull(); + }); + + it('generates unique ids for server stream render', async () => { + function App(props) { + const idOne = useOpaqueIdentifier(); + const idTwo = useOpaqueIdentifier(); + return ( +
+
+
+ + +
+ ); + } + + const domNode = await streamRender(); + expect(domNode.children.length).toEqual(4); + expect(domNode.children[0].getAttribute('aria-labelledby')).toEqual( + domNode.children[1].getAttribute('id'), + ); + expect(domNode.children[2].getAttribute('aria-labelledby')).toEqual( + domNode.children[3].getAttribute('id'), + ); + expect(domNode.children[0].getAttribute('aria-labelledby')).not.toEqual( + domNode.children[2].getAttribute('aria-labelledby'), + ); + expect( + domNode.children[0].getAttribute('aria-labelledby'), + ).not.toBeNull(); + expect( + domNode.children[2].getAttribute('aria-labelledby'), + ).not.toBeNull(); + }); + + it('generates unique ids for client render', async () => { + function App(props) { + const idOne = useOpaqueIdentifier(); + const idTwo = useOpaqueIdentifier(); + return ( +
+
+
+ + +
+ ); + } + + const domNode = await clientCleanRender(); + expect(domNode.children.length).toEqual(4); + expect(domNode.children[0].getAttribute('aria-labelledby')).toEqual( + domNode.children[1].getAttribute('id'), + ); + expect(domNode.children[2].getAttribute('aria-labelledby')).toEqual( + domNode.children[3].getAttribute('id'), + ); + expect(domNode.children[0].getAttribute('aria-labelledby')).not.toEqual( + domNode.children[2].getAttribute('aria-labelledby'), + ); + expect( + domNode.children[0].getAttribute('aria-labelledby'), + ).not.toBeNull(); + expect( + domNode.children[2].getAttribute('aria-labelledby'), + ).not.toBeNull(); + }); + + it('generates unique ids for client render on good server markup', async () => { + function App(props) { + const idOne = useOpaqueIdentifier(); + const idTwo = useOpaqueIdentifier(); + return ( +
+
+
+ + +
+ ); + } + + const domNode = await clientRenderOnServerString(); + expect(domNode.children.length).toEqual(4); + expect(domNode.children[0].getAttribute('aria-labelledby')).toEqual( + domNode.children[1].getAttribute('id'), + ); + expect(domNode.children[2].getAttribute('aria-labelledby')).toEqual( + domNode.children[3].getAttribute('id'), + ); + expect(domNode.children[0].getAttribute('aria-labelledby')).not.toEqual( + domNode.children[2].getAttribute('aria-labelledby'), + ); + expect( + domNode.children[0].getAttribute('aria-labelledby'), + ).not.toBeNull(); + expect( + domNode.children[2].getAttribute('aria-labelledby'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier does not change id even if the component updates during client render', async () => { + let _setShowId; + function App() { + const id = useOpaqueIdentifier(); + const [showId, setShowId] = useState(false); + _setShowId = setShowId; + return ( +
+
+ {showId &&
} +
+ ); + } + + const domNode = await clientCleanRender(); + const oldClientId = domNode.children[0].getAttribute('aria-labelledby'); + + expect(domNode.children.length).toEqual(1); + expect(oldClientId).not.toBeNull(); + + await ReactTestUtils.act(async () => _setShowId(true)); + + expect(domNode.children.length).toEqual(2); + expect(domNode.children[0].getAttribute('aria-labelledby')).toEqual( + domNode.children[1].getAttribute('id'), + ); + expect(domNode.children[0].getAttribute('aria-labelledby')).toEqual( + oldClientId, + ); + }); + + it('useOpaqueIdentifier: IDs match when, after hydration, a new component that uses the ID is rendered', async () => { + let _setShowDiv; + function App() { + const id = useOpaqueIdentifier(); + const [showDiv, setShowDiv] = useState(false); + _setShowDiv = setShowDiv; + + return ( +
+
Child One
+ {showDiv &&
Child Two
} +
+ ); + } + + const container = document.createElement('div'); + document.body.append(container); + + container.innerHTML = ReactDOMServer.renderToString(); + const root = ReactDOM.createRoot(container, {hydrate: true}); + root.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + expect(container.children[0].children.length).toEqual(1); + const oldServerId = container.children[0].children[0].getAttribute( + 'id', + ); + expect(oldServerId).not.toBeNull(); + + await ReactTestUtils.act(async () => { + _setShowDiv(true); + }); + expect(container.children[0].children.length).toEqual(2); + expect(container.children[0].children[0].getAttribute('id')).toEqual( + container.children[0].children[1].getAttribute('id'), + ); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toEqual(oldServerId); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier: IDs match when, after hydration, a new component that uses the ID is rendered for legacy', async () => { + let _setShowDiv; + function App() { + const id = useOpaqueIdentifier(); + const [showDiv, setShowDiv] = useState(false); + _setShowDiv = setShowDiv; + + return ( +
+
Child One
+ {showDiv &&
Child Two
} +
+ ); + } + + const container = document.createElement('div'); + document.body.append(container); + + container.innerHTML = ReactDOMServer.renderToString(); + ReactDOM.hydrate(, container); + + expect(container.children[0].children.length).toEqual(1); + const oldServerId = container.children[0].children[0].getAttribute( + 'id', + ); + expect(oldServerId).not.toBeNull(); + + await ReactTestUtils.act(async () => { + _setShowDiv(true); + }); + expect(container.children[0].children.length).toEqual(2); + expect(container.children[0].children[0].getAttribute('id')).toEqual( + container.children[0].children[1].getAttribute('id'), + ); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toEqual(oldServerId); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier: ID is not used during hydration but is used in an update', async () => { + let _setShow; + function App() { + Scheduler.unstable_yieldValue('App'); + const id = useOpaqueIdentifier(); + const [show, setShow] = useState(false); + _setShow = setShow; + return ( +
+ {'Child One'} +
+ ); + } + + const container = document.createElement('div'); + document.body.append(container); + container.innerHTML = ReactDOMServer.renderToString(); + const root = ReactDOM.createRoot(container, {hydrate: true}); + ReactTestUtils.act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['App', 'App']); + // The ID goes from not being used to being added to the page + ReactTestUtils.act(() => { + _setShow(true); + }); + expect(Scheduler).toHaveYielded(['App', 'App']); + expect( + container.getElementsByTagName('span')[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier: ID is not used during hydration but is used in an update in legacy', async () => { + let _setShow; + function App() { + Scheduler.unstable_yieldValue('App'); + const id = useOpaqueIdentifier(); + const [show, setShow] = useState(false); + _setShow = setShow; + return ( +
+ {'Child One'} +
+ ); + } + + const container = document.createElement('div'); + document.body.append(container); + container.innerHTML = ReactDOMServer.renderToString(); + ReactDOM.hydrate(, container); + expect(Scheduler).toHaveYielded(['App', 'App']); + // The ID goes from not being used to being added to the page + ReactTestUtils.act(() => { + _setShow(true); + }); + expect(Scheduler).toHaveYielded(['App']); + expect( + container.getElementsByTagName('span')[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifierr: flushSync', async () => { + let _setShow; + function App() { + const id = useOpaqueIdentifier(); + const [show, setShow] = useState(false); + _setShow = setShow; + return ( +
+ {'Child One'} +
+ ); + } + + const container = document.createElement('div'); + document.body.append(container); + container.innerHTML = ReactDOMServer.renderToString(); + const root = ReactDOM.createRoot(container, {hydrate: true}); + ReactTestUtils.act(() => { + root.render(); + }); + + // The ID goes from not being used to being added to the page + ReactTestUtils.act(() => { + ReactDOM.flushSync(() => { + _setShow(true); + }); + }); + expect( + container.getElementsByTagName('span')[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier: children with id hydrates before other children if ID updates', async () => { + let _setShow; + + const child1Ref = React.createRef(); + const childWithIDRef = React.createRef(); + const setShowRef = React.createRef(); + + // RENAME THESE + function Child1() { + Scheduler.unstable_yieldValue('Child One'); + return {'Child One'}; + } + + function Child2() { + Scheduler.unstable_yieldValue('Child Two'); + return {'Child Two'}; + } + + const Children = React.memo(function Children() { + return ( + + + + + ); + }); + + function ChildWithID({parentID}) { + Scheduler.unstable_yieldValue('Child with ID'); + return ( + + {'Child with ID'} + + ); + } + + const ChildrenWithID = React.memo(function ChildrenWithID({parentID}) { + return ( + + + + ); + }); + + function App() { + const id = useOpaqueIdentifier(); + const [show, setShow] = useState(false); + _setShow = setShow; + return ( +
+ + + {show && ( + + {'Child Three'} + + )} +
+ ); + } + + const container = document.createElement('div'); + container.innerHTML = ReactDOMServer.renderToString(); + expect(Scheduler).toHaveYielded([ + 'Child One', + 'Child Two', + 'Child with ID', + ]); + expect(container.textContent).toEqual( + 'Child OneChild TwoChild with ID', + ); + + const serverId = container + .getElementsByTagName('span')[2] + .getAttribute('id'); + expect(serverId).not.toBeNull(); + + const childOneSpan = container.getElementsByTagName('span')[0]; + + const root = ReactDOM.createRoot(container, {hydrate: true}); + root.render(); + expect(Scheduler).toHaveYielded([]); + + //Hydrate just child one before updating state + expect(Scheduler).toFlushAndYieldThrough(['Child One']); + expect(child1Ref.current).toBe(null); + expect(Scheduler).toHaveYielded([]); + + ReactTestUtils.act(() => { + _setShow(true); + + // State update should trigger the ID to update, which changes the props + // of ChildWithID. This should cause ChildWithID to hydrate before Children + expect(Scheduler).toFlushAndYieldThrough([ + 'Child with ID', + 'Child with ID', + 'Child with ID', + 'Child One', + 'Child Two', + ]); + + expect(child1Ref.current).toBe(null); + expect(childWithIDRef.current).toEqual( + container.getElementsByTagName('span')[2], + ); + + expect(setShowRef.current).toEqual( + container.getElementsByTagName('span')[3], + ); + + expect(childWithIDRef.current.getAttribute('id')).toEqual( + setShowRef.current.getAttribute('aria-labelledby'), + ); + expect(childWithIDRef.current.getAttribute('id')).not.toEqual( + serverId, + ); + }); + + // Children hydrates after ChildWithID + expect(child1Ref.current).toBe(childOneSpan); + + Scheduler.unstable_flushAll(); + + expect(Scheduler).toHaveYielded([]); + }); + + it('useOpaqueIdentifier: IDs match when part of the DOM tree is server rendered and part is client rendered', async () => { + let suspend = true; + let resolve; + const promise = new Promise( + resolvePromise => (resolve = resolvePromise), + ); + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return text; + } + } + + function RenderedChild() { + useEffect(() => { + Scheduler.unstable_yieldValue('Child did commit'); + }); + return null; + } + + function App() { + const id = useOpaqueIdentifier(); + useEffect(() => { + Scheduler.unstable_yieldValue('Did commit'); + }); + return ( +
+
Child One
+ + +
+ +
+
+
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + + suspend = true; + const root = ReactDOM.createRoot(container, {hydrate: true}); + await ReactTestUtils.act(async () => { + root.render(); + }); + jest.runAllTimers(); + expect(Scheduler).toHaveYielded(['Child did commit', 'Did commit']); + expect(Scheduler).toFlushAndYield([]); + + const serverId = container.children[0].children[0].getAttribute('id'); + expect(container.children[0].children.length).toEqual(1); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toBeNull(); + + await ReactTestUtils.act(async () => { + suspend = false; + resolve(); + await promise; + }); + + expect(Scheduler).toHaveYielded(['Child did commit', 'Did commit']); + expect(Scheduler).toFlushAndYield([]); + jest.runAllTimers(); + + expect(container.children[0].children.length).toEqual(2); + expect(container.children[0].children[0].getAttribute('id')).toEqual( + container.children[0].children[1].getAttribute('id'), + ); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toEqual(serverId); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier warn when there is a hydration error', async () => { + function Child({appId}) { + return
; + } + function App() { + const id = useOpaqueIdentifier(); + return ; + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + // This is the wrong HTML string + container.innerHTML = ''; + ReactDOM.createRoot(container, {hydrate: true}).render(); + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow(), + ).toErrorDev([ + 'Warning: Expected server HTML to contain a matching
in
.', + ]); + }); + + it('useOpaqueIdentifier: IDs match when part of the DOM tree is server rendered and part is client rendered', async () => { + let suspend = true; + + function Child({text}) { + if (suspend) { + throw new Promise(() => {}); + } else { + return text; + } + } + + function RenderedChild() { + useEffect(() => { + Scheduler.unstable_yieldValue('Child did commit'); + }); + return null; + } + + function App() { + const id = useOpaqueIdentifier(); + useEffect(() => { + Scheduler.unstable_yieldValue('Did commit'); + }); + return ( +
+
Child One
+ + +
+ +
+
+
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + + suspend = false; + const root = ReactDOM.createRoot(container, {hydrate: true}); + await ReactTestUtils.act(async () => { + root.render(); + }); + jest.runAllTimers(); + expect(Scheduler).toHaveYielded([ + 'Child did commit', + 'Did commit', + 'Child did commit', + 'Did commit', + ]); + expect(Scheduler).toFlushAndYield([]); + + expect(container.children[0].children.length).toEqual(2); + expect(container.children[0].children[0].getAttribute('id')).toEqual( + container.children[0].children[1].getAttribute('id'), + ); + expect( + container.children[0].children[0].getAttribute('id'), + ).not.toBeNull(); + }); + + it('useOpaqueIdentifier warn when there is a hydration error', async () => { + function Child({appId}) { + return
; + } + function App() { + const id = useOpaqueIdentifier(); + return ; + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + // This is the wrong HTML string + container.innerHTML = ''; + ReactDOM.createRoot(container, {hydrate: true}).render(); + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow(), + ).toErrorDev([ + 'Warning: Expected server HTML to contain a matching
in
.', + ]); + }); + + it('useOpaqueIdentifier throws when there is a hydration error and we are using ID as a string', async () => { + function Child({appId}) { + return
; + } + function App() { + const id = useOpaqueIdentifier(); + return ; + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + // This is the wrong HTML string + container.innerHTML = ''; + ReactDOM.createRoot(container, {hydrate: true}).render(); + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow( + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ), + ).toErrorDev( + [ + 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', + 'Warning: Did not expect server HTML to contain a in
.', + ], + {withoutStack: 1}, + ); + }); + + it('useOpaqueIdentifier throws when there is a hydration error and we are using ID as a string', async () => { + function Child({appId}) { + return
; + } + function App() { + const id = useOpaqueIdentifier(); + return ; + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + // This is the wrong HTML string + container.innerHTML = ''; + ReactDOM.createRoot(container, {hydrate: true}).render(); + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow( + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ), + ).toErrorDev( + [ + 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', + 'Warning: Did not expect server HTML to contain a in
.', + ], + {withoutStack: 1}, + ); + }); + + it('useOpaqueIdentifier throws if you try to use the result as a string in a child component', async () => { + function Child({appId}) { + return
; + } + function App() { + const id = useOpaqueIdentifier(); + return ; + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + ReactDOM.createRoot(container, {hydrate: true}).render(); + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow( + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ), + ).toErrorDev( + [ + 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', + 'Warning: Did not expect server HTML to contain a
in
.', + ], + {withoutStack: 1}, + ); + }); + + it('useOpaqueIdentifier throws if you try to use the result as a string', async () => { + function App() { + const id = useOpaqueIdentifier(); + return
; + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + ReactDOM.createRoot(container, {hydrate: true}).render(); + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow( + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ), + ).toErrorDev( + [ + 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', + 'Warning: Did not expect server HTML to contain a
in
.', + ], + {withoutStack: 1}, + ); + }); + + it('useOpaqueIdentifier throws if you try to use the result as a string in a child component wrapped in a Suspense', async () => { + function Child({appId}) { + return
; + } + function App() { + const id = useOpaqueIdentifier(); + return ( + + + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + + ReactDOM.createRoot(container, {hydrate: true}).render(); + + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow( + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ), + ).toErrorDev([ + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ]); + }); + + it('useOpaqueIdentifier throws if you try to add the result as a number in a child component wrapped in a Suspense', async () => { + function Child({appId}) { + return
; + } + function App() { + const [show] = useState(false); + const id = useOpaqueIdentifier(); + return ( + + {show &&
} + + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + + ReactDOM.createRoot(container, {hydrate: true}).render(); + + expect(() => + expect(() => Scheduler.unstable_flushAll()).toThrow( + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ), + ).toErrorDev([ + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ]); + }); + + it('useOpaqueIdentifier with two opaque identifiers on the same page', () => { + let _setShow; + + function App() { + const id1 = useOpaqueIdentifier(); + const id2 = useOpaqueIdentifier(); + const [show, setShow] = useState(true); + _setShow = setShow; + + return ( +
+ + {show ? ( + {'Child'} + ) : ( + {'Child'} + )} + + {'test'} +
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + container.innerHTML = ReactDOMServer.renderToString(); + + const serverID = container + .getElementsByTagName('span')[0] + .getAttribute('id'); + expect(serverID).not.toBeNull(); + expect( + container + .getElementsByTagName('span')[1] + .getAttribute('aria-labelledby'), + ).toEqual(serverID); + + ReactDOM.createRoot(container, {hydrate: true}).render(); + jest.runAllTimers(); + expect(Scheduler).toHaveYielded([]); + expect(Scheduler).toFlushAndYield([]); + + ReactTestUtils.act(() => { + _setShow(false); + }); + + expect( + container + .getElementsByTagName('span')[1] + .getAttribute('aria-labelledby'), + ).toEqual(serverID); + expect( + container.getElementsByTagName('span')[0].getAttribute('id'), + ).not.toEqual(serverID); + expect( + container.getElementsByTagName('span')[0].getAttribute('id'), + ).not.toBeNull(); + }); + }); + } }); diff --git a/packages/react-dom/src/client/DOMPropertyOperations.js b/packages/react-dom/src/client/DOMPropertyOperations.js index 2fcf3d2efc37..2dcb1ace8ab9 100644 --- a/packages/react-dom/src/client/DOMPropertyOperations.js +++ b/packages/react-dom/src/client/DOMPropertyOperations.js @@ -20,6 +20,7 @@ import { disableJavaScriptURLs, enableTrustedTypesIntegration, } from 'shared/ReactFeatureFlags'; +import {isOpaqueHydratingObject} from './ReactDOMHostConfig'; import type {PropertyInfo} from '../shared/DOMProperty'; @@ -107,6 +108,13 @@ export function getValueForAttribute( if (!isAttributeNameSafe(name)) { return; } + + // If the object is an opaque reference ID, it's expected that + // the next prop is different than the server value, so just return + // expected + if (isOpaqueHydratingObject(expected)) { + return expected; + } if (!node.hasAttribute(name)) { return expected === undefined ? undefined : null; } diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index e8a0396e24a3..2f0190084ba5 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -84,6 +84,7 @@ import possibleStandardNames from '../shared/possibleStandardNames'; import {validateProperties as validateARIAProperties} from '../shared/ReactDOMInvalidARIAHook'; import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook'; import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook'; +import {REACT_OPAQUE_ID_TYPE} from 'shared/ReactSymbols'; import { enableDeprecatedFlareAPI, @@ -849,6 +850,15 @@ export function diffProperties( // to update this element. updatePayload = []; } + } else if ( + typeof nextProp === 'object' && + nextProp !== null && + nextProp.$$typeof === REACT_OPAQUE_ID_TYPE + ) { + // If we encounter useOpaqueReference's opaque object, this means we are hydrating. + // In this case, call the opaque object's toString function which generates a new client + // ID so client and server IDs match and throws to rerender. + nextProp.toString(); } else { // For any other property we always add it to the queue and then we // filter it out using the whitelist during the commit. diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 05b6f50ebeb7..b8b0050de7a5 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -58,6 +58,7 @@ import { } from '../shared/HTMLNodeType'; import dangerousStyleValue from '../shared/dangerousStyleValue'; +import {REACT_OPAQUE_ID_TYPE} from 'shared/ReactSymbols'; import { mountEventResponder, unmountEventResponder, @@ -150,6 +151,13 @@ export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; export type RendererInspectionConfig = $ReadOnly<{||}>; +export opaque type OpaqueIDType = + | string + | { + toString: () => string | void, + valueOf: () => string | void, + }; + type SelectionInformation = {| activeElementDetached: null | HTMLElement, focusedElem: null | HTMLElement, @@ -1147,6 +1155,48 @@ export function getInstanceFromNode(node: HTMLElement): null | Object { return getClosestInstanceFromNode(node) || null; } +let clientId: number = 0; +export function makeClientId(): OpaqueIDType { + return 'r:' + (clientId++).toString(36); +} + +export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { + const id = 'r:' + (clientId++).toString(36); + return { + toString() { + warnOnAccessInDEV(); + return id; + }, + valueOf() { + warnOnAccessInDEV(); + return id; + }, + }; +} + +let serverId: number = 0; +export function makeServerId(): OpaqueIDType { + return 'R:' + (serverId++).toString(36); +} + +export function isOpaqueHydratingObject(value: mixed): boolean { + return ( + value !== null && + typeof value === 'object' && + value.$$typeof === REACT_OPAQUE_ID_TYPE + ); +} + +export function makeOpaqueHydratingObject( + attemptToReadValue: () => void, +): OpaqueIDType { + return { + $$typeof: REACT_OPAQUE_ID_TYPE, + toString: attemptToReadValue, + valueOf: attemptToReadValue, + }; +} + export function registerEvent( event: ReactDOMListenerEvent, rootContainerInstance: Container, diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 963c025bb7bc..0ccbf9f41e77 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -12,6 +12,8 @@ import type { TimeoutConfig, } from 'react-reconciler/src/ReactFiberHooks'; import type {ThreadID} from './ReactThreadIDAllocator'; +import type {OpaqueIDType} from 'react-reconciler/src/ReactFiberHostConfig'; + import type { MutableSource, MutableSourceGetSnapshotFn, @@ -23,6 +25,7 @@ import type {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig import type {ReactDOMListenerMap} from '../shared/ReactDOMTypes'; import {validateContextBounds} from './ReactPartialRendererContext'; +import {makeServerId} from '../client/ReactDOMHostConfig'; import invariant from 'shared/invariant'; import is from 'shared/objectIs'; @@ -491,6 +494,10 @@ function useTransition( return [startTransition, false]; } +function useOpaqueIdentifier(): OpaqueIDType { + return makeServerId(); +} + function useEvent(event: any): ReactDOMListenerMap { return { clear: noop, @@ -525,6 +532,7 @@ export const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useEvent, + useOpaqueIdentifier, // Subscriptions are not setup in a server environment. useMutableSource, }; diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index cd77692132cc..91aa98398114 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -80,6 +80,7 @@ export type NoTimeout = -1; export type ReactListenerEvent = Object; export type ReactListenerMap = Object; export type ReactListener = Object; +export type OpaqueIDType = void; export type RendererInspectionConfig = $ReadOnly<{| // Deprecated. Replaced with getInspectorDataForViewAtPoint. @@ -486,6 +487,28 @@ export function beforeRemoveInstance(instance: any) { // noop } +export function isOpaqueHydratingObject(value: mixed): boolean { + throw new Error('Not yet implemented'); +} + +export function makeOpaqueHydratingObject( + attemptToReadValue: () => void, +): OpaqueIDType { + throw new Error('Not yet implemented.'); +} + +export function makeClientId(): OpaqueIDType { + throw new Error('Not yet implemented'); +} + +export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { + throw new Error('Not yet implemented'); +} + +export function makeServerId(): OpaqueIDType { + throw new Error('Not yet implemented'); +} + export function registerEvent(event: any, rootContainerInstance: Container) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 990c4d2123a3..a6ed4de72c39 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -47,6 +47,7 @@ export type ChildSet = void; // Unused export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; +export type OpaqueIDType = void; export type RendererInspectionConfig = $ReadOnly<{| // Deprecated. Replaced with getInspectorDataForViewAtPoint. @@ -535,6 +536,28 @@ export function beforeRemoveInstance(instance: any) { // noop } +export function isOpaqueHydratingObject(value: mixed): boolean { + throw new Error('Not yet implemented'); +} + +export function makeOpaqueHydratingObject( + attemptToReadValue: () => void, +): OpaqueIDType { + throw new Error('Not yet implemented.'); +} + +export function makeClientId(): OpaqueIDType { + throw new Error('Not yet implemented'); +} + +export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { + throw new Error('Not yet implemented'); +} + +export function makeServerId(): OpaqueIDType { + throw new Error('Not yet implemented'); +} + export function registerEvent(event: any, rootContainerInstance: Container) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-reconciler/src/ReactCurrentFiber.js b/packages/react-reconciler/src/ReactCurrentFiber.js index 617dd1ff87f3..dfdf67e692b0 100644 --- a/packages/react-reconciler/src/ReactCurrentFiber.js +++ b/packages/react-reconciler/src/ReactCurrentFiber.js @@ -103,3 +103,9 @@ export function setIsRendering(rendering: boolean) { isRendering = rendering; } } + +export function getIsRendering() { + if (__DEV__) { + return isRendering; + } +} diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 580311570542..81514012002e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -23,6 +23,7 @@ import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; import type {FiberRoot} from './ReactFiberRoot'; import type { + OpaqueIDType, ReactListenerEvent, ReactListenerMap, ReactListener, @@ -33,6 +34,7 @@ import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; import {markRootExpiredAtTime} from './ReactFiberRoot'; import {NoWork, Sync} from './ReactFiberExpirationTime'; +import {NoMode, BlockingMode} from './ReactTypeOfMode'; import {readContext} from './ReactFiberNewContext'; import {createDeprecatedResponderListener} from './ReactFiberDeprecatedEvents'; import { @@ -74,6 +76,12 @@ import { runWithPriority, getCurrentPriorityLevel, } from './SchedulerWithReactIntegration'; +import {getIsHydrating} from './ReactFiberHydrationContext'; +import { + makeClientId, + makeClientIdInDEV, + makeOpaqueHydratingObject, +} from './ReactFiberHostConfig'; import { getLastPendingExpirationTime, getWorkInProgressVersion, @@ -83,6 +91,7 @@ import { warnAboutMultipleRenderersDEV, } from './ReactMutableSource'; import {getRootHostContainer} from './ReactFiberHostContext'; +import {getIsRendering} from './ReactCurrentFiber'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -132,6 +141,7 @@ export type Dispatcher = {| subscribe: MutableSourceSubscribeFn, ): Snapshot, useEvent(event: ReactListenerEvent): ReactListenerMap, + useOpaqueIdentifier(): OpaqueIDType | void, |}; type Update = {| @@ -166,10 +176,13 @@ export type HookType = | 'useDeferredValue' | 'useTransition' | 'useMutableSource' - | 'useEvent'; + | 'useEvent' + | 'useOpaqueIdentifier'; let didWarnAboutMismatchedHooksForComponent; +let didWarnAboutUseOpaqueIdentifier; if (__DEV__) { + didWarnAboutUseOpaqueIdentifier = {}; didWarnAboutMismatchedHooksForComponent = new Set(); } @@ -564,6 +577,8 @@ export function resetHooksAfterThrow(): void { hookTypesUpdateIndexDev = -1; currentHookNameInDev = null; + + isUpdatingOpaqueValueInRenderPhase = false; } didScheduleRenderPhaseUpdate = false; @@ -1569,6 +1584,93 @@ function rerenderTransition( return [start, isPending]; } +let isUpdatingOpaqueValueInRenderPhase = false; +export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void { + if (__DEV__) { + return isUpdatingOpaqueValueInRenderPhase; + } +} + +function warnOnOpaqueIdentifierAccessInDEV(fiber) { + if (__DEV__) { + // TODO: Should warn in effects and callbacks, too + const name = getComponentName(fiber.type) || 'Unknown'; + if (getIsRendering() && !didWarnAboutUseOpaqueIdentifier[name]) { + console.error( + 'The object passed back from useOpaqueIdentifier is meant to be ' + + 'passed through to attributes only. Do not read the ' + + 'value directly.', + ); + didWarnAboutUseOpaqueIdentifier[name] = true; + } + } +} + +function mountOpaqueIdentifier(): OpaqueIDType | void { + const makeId = __DEV__ + ? makeClientIdInDEV.bind( + null, + warnOnOpaqueIdentifierAccessInDEV.bind(null, currentlyRenderingFiber), + ) + : makeClientId; + + if (getIsHydrating()) { + let didUpgrade = false; + const fiber = currentlyRenderingFiber; + const readValue = () => { + if (!didUpgrade) { + // Only upgrade once. This works even inside the render phase because + // the update is added to a shared queue, which outlasts the + // in-progress render. + didUpgrade = true; + if (__DEV__) { + isUpdatingOpaqueValueInRenderPhase = true; + setId(makeId()); + isUpdatingOpaqueValueInRenderPhase = false; + warnOnOpaqueIdentifierAccessInDEV(fiber); + } else { + setId(makeId()); + } + } + invariant( + false, + 'The object passed back from useOpaqueIdentifier is meant to be ' + + 'passed through to attributes only. Do not read the value directly.', + ); + }; + const id = makeOpaqueHydratingObject(readValue); + + const setId = mountState(id)[1]; + + if ((currentlyRenderingFiber.mode & BlockingMode) === NoMode) { + currentlyRenderingFiber.effectTag |= UpdateEffect | PassiveEffect; + pushEffect( + HookHasEffect | HookPassive, + () => { + setId(makeId()); + }, + undefined, + null, + ); + } + return id; + } else { + const id = makeId(); + mountState(id); + return id; + } +} + +function updateOpaqueIdentifier(): OpaqueIDType | void { + const id = updateState(undefined)[0]; + return id; +} + +function rerenderOpaqueIdentifier(): OpaqueIDType | void { + const id = rerenderState(undefined)[0]; + return id; +} + function dispatchAction( fiber: Fiber, queue: UpdateQueue, @@ -1848,6 +1950,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useTransition: throwInvalidHookError, useMutableSource: throwInvalidHookError, useEvent: throwInvalidHookError, + useOpaqueIdentifier: throwInvalidHookError, }; const HooksDispatcherOnMount: Dispatcher = { @@ -1868,6 +1971,7 @@ const HooksDispatcherOnMount: Dispatcher = { useTransition: mountTransition, useMutableSource: mountMutableSource, useEvent: mountEventListener, + useOpaqueIdentifier: mountOpaqueIdentifier, }; const HooksDispatcherOnUpdate: Dispatcher = { @@ -1888,6 +1992,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useTransition: updateTransition, useMutableSource: updateMutableSource, useEvent: updateEventListener, + useOpaqueIdentifier: updateOpaqueIdentifier, }; const HooksDispatcherOnRerender: Dispatcher = { @@ -1908,6 +2013,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useTransition: rerenderTransition, useMutableSource: updateMutableSource, useEvent: updateEventListener, + useOpaqueIdentifier: rerenderOpaqueIdentifier, }; let HooksDispatcherOnMountInDEV: Dispatcher | null = null; @@ -1944,7 +2050,6 @@ if (__DEV__) { ): T { return readContext(context, observedBits); }, - useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; mountHookTypesDev(); @@ -2071,6 +2176,11 @@ if (__DEV__) { mountHookTypesDev(); return mountEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + mountHookTypesDev(); + return mountOpaqueIdentifier(); + }, }; HooksDispatcherOnMountWithHookTypesInDEV = { @@ -2080,7 +2190,6 @@ if (__DEV__) { ): T { return readContext(context, observedBits); }, - useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; updateHookTypesDev(); @@ -2202,6 +2311,11 @@ if (__DEV__) { updateHookTypesDev(); return mountEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + updateHookTypesDev(); + return mountOpaqueIdentifier(); + }, }; HooksDispatcherOnUpdateInDEV = { @@ -2211,7 +2325,6 @@ if (__DEV__) { ): T { return readContext(context, observedBits); }, - useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; updateHookTypesDev(); @@ -2333,6 +2446,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + updateHookTypesDev(); + return updateOpaqueIdentifier(); + }, }; HooksDispatcherOnRerenderInDEV = { @@ -2464,6 +2582,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + updateHookTypesDev(); + return rerenderOpaqueIdentifier(); + }, }; InvalidNestedHooksDispatcherOnMountInDEV = { @@ -2474,7 +2597,6 @@ if (__DEV__) { warnInvalidContextAccess(); return readContext(context, observedBits); }, - useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; warnInvalidHookAccess(); @@ -2611,6 +2733,12 @@ if (__DEV__) { mountHookTypesDev(); return mountEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountOpaqueIdentifier(); + }, }; InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -2621,7 +2749,6 @@ if (__DEV__) { warnInvalidContextAccess(); return readContext(context, observedBits); }, - useCallback(callback: T, deps: Array | void | null): T { currentHookNameInDev = 'useCallback'; warnInvalidHookAccess(); @@ -2758,6 +2885,12 @@ if (__DEV__) { updateHookTypesDev(); return updateEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateOpaqueIdentifier(); + }, }; InvalidNestedHooksDispatcherOnRerenderInDEV = { @@ -2905,5 +3038,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEventListener(event); }, + useOpaqueIdentifier(): OpaqueIDType | void { + currentHookNameInDev = 'useOpaqueIdentifier'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return rerenderOpaqueIdentifier(); + }, }; } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 365bfb48dea3..7f06c4d0ed60 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -485,9 +485,14 @@ function resetHydrationState(): void { isHydrating = false; } +function getIsHydrating(): boolean { + return isHydrating; +} + export { warnIfHydrating, enterHydrationState, + getIsHydrating, reenterHydrationStateFromDehydratedSuspenseInstance, resetHydrationState, tryToClaimNextHydratableInstance, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 5aab14db944b..b82ed04cf75c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -145,7 +145,11 @@ import { } from './ReactFiberCommitWork'; import {enqueueUpdate} from './ReactUpdateQueue'; import {resetContextDependencies} from './ReactFiberNewContext'; -import {resetHooksAfterThrow, ContextOnlyDispatcher} from './ReactFiberHooks'; +import { + resetHooksAfterThrow, + ContextOnlyDispatcher, + getIsUpdatingOpaqueValueInRenderPhaseInDEV, +} from './ReactFiberHooks'; import {createCapturedValue} from './ReactCapturedValue'; import { @@ -2842,7 +2846,8 @@ function warnAboutRenderPhaseUpdatesInDEV(fiber) { if (__DEV__) { if ( ReactCurrentDebugFiberIsRenderingInDEV && - (executionContext & RenderContext) !== NoContext + (executionContext & RenderContext) !== NoContext && + !getIsUpdatingOpaqueValueInRenderPhaseInDEV() ) { switch (fiber.tag) { case FunctionComponent: diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index dc4517013450..a9f6286e3bd8 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -38,6 +38,7 @@ export opaque type ChildSet = mixed; // eslint-disable-line no-undef export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef export opaque type NoTimeout = mixed; // eslint-disable-line no-undef export opaque type RendererInspectionConfig = mixed; // eslint-disable-line no-undef +export opaque type OpaqueIDType = mixed; export type EventResponder = any; export type ReactListenerEvent = Object; export type ReactListenerMap = Object; @@ -82,6 +83,12 @@ export const mountEventListener = $$$hostConfig.mountEventListener; export const unmountEventListener = $$$hostConfig.unmountEventListener; export const validateEventListenerTarget = $$$hostConfig.validateEventListenerTarget; +export const isOpaqueHydratingObject = $$$hostConfig.isOpaqueHydratingObject; +export const makeOpaqueHydratingObject = + $$$hostConfig.makeOpaqueHydratingObject; +export const makeClientId = $$$hostConfig.makeClientId; +export const makeClientIdInDEV = $$$hostConfig.makeClientIdInDEV; +export const makeServerId = $$$hostConfig.makeServerId; // ------------------- // Mutation diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index f4f66a3cd1c0..c3eda48d2a4e 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -14,6 +14,7 @@ import type { } from 'shared/ReactTypes'; import {enableDeprecatedFlareAPI} from 'shared/ReactFeatureFlags'; +import {REACT_OPAQUE_ID_TYPE} from 'shared/ReactSymbols'; export type Type = string; export type Props = Object; @@ -44,6 +45,12 @@ export type ChildSet = void; // Unused export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; export type EventResponder = any; +export opaque type OpaqueIDType = + | string + | { + toString: () => string | void, + valueOf: () => string | void, + }; export type ReactListenerEvent = Object; export type ReactListenerMap = Object; @@ -381,6 +388,48 @@ export function beforeRemoveInstance(instance: any) { // noop } +let clientId: number = 0; +export function makeClientId(): OpaqueIDType { + return 'c_' + (clientId++).toString(36); +} + +export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { + const id = 'c_' + (clientId++).toString(36); + return { + toString() { + warnOnAccessInDEV(); + return id; + }, + valueOf() { + warnOnAccessInDEV(); + return id; + }, + }; +} + +let serverId: number = 0; +export function makeServerId(): OpaqueIDType { + return 's_' + (serverId++).toString(36); +} + +export function isOpaqueHydratingObject(value: mixed): boolean { + return ( + value !== null && + typeof value === 'object' && + value.$$typeof === REACT_OPAQUE_ID_TYPE + ); +} + +export function makeOpaqueHydratingObject( + attemptToReadValue: () => void, +): OpaqueIDType { + return { + $$typeof: REACT_OPAQUE_ID_TYPE, + toString: attemptToReadValue, + valueOf: attemptToReadValue, + }; +} + export function registerEvent(event: any, rootContainerInstance: Container) { throw new Error('Not yet implemented.'); } diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index ca2834be0fca..998de2bec5ca 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -50,5 +50,6 @@ export { DEPRECATED_createResponder, // enableScopeAPI unstable_createScope, + unstable_useOpaqueIdentifier, } from './src/React'; export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX'; diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 79f1e235fe74..9c2a3d4d9355 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -45,4 +45,5 @@ export { unstable_withSuspenseConfig, // enableBlocksAPI block, + unstable_useOpaqueIdentifier, } from './src/React'; diff --git a/packages/react/index.js b/packages/react/index.js index f97dd1fc0cd4..3ca22840f999 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -76,4 +76,5 @@ export { DEPRECATED_createResponder, unstable_createFundamental, unstable_createScope, + unstable_useOpaqueIdentifier, } from './src/React'; diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index c42da6dd343a..76a904d0c8cf 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -49,5 +49,6 @@ export { DEPRECATED_createResponder, // enableScopeAPI unstable_createScope, + unstable_useOpaqueIdentifier, } from './src/React'; export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX'; diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 8484ef5886e9..d7896ad29972 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -45,6 +45,7 @@ import { useResponder, useTransition, useDeferredValue, + useOpaqueIdentifier, } from './ReactHooks'; import {withSuspenseConfig} from './ReactBatchConfig'; import { @@ -117,4 +118,5 @@ export { createFundamental as unstable_createFundamental, // enableScopeAPI createScope as unstable_createScope, + useOpaqueIdentifier as unstable_useOpaqueIdentifier, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 38a6a363481b..acc47e1d6b9f 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -15,6 +15,8 @@ import type { ReactEventResponder, ReactEventResponderListener, } from 'shared/ReactTypes'; +import type {OpaqueIDType} from 'react-reconciler/src/ReactFiberHostConfig'; + import invariant from 'shared/invariant'; import {REACT_RESPONDER_TYPE} from 'shared/ReactSymbols'; @@ -181,6 +183,11 @@ export function useDeferredValue(value: T, config: ?Object): T { return dispatcher.useDeferredValue(value, config); } +export function useOpaqueIdentifier(): OpaqueIDType | void { + const dispatcher = resolveDispatcher(); + return dispatcher.useOpaqueIdentifier(); +} + export function useMutableSource( source: MutableSource, getSnapshot: MutableSourceGetSnapshotFn, diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index 77979459e962..53c994cf3bfe 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -26,6 +26,7 @@ export let REACT_SERVER_BLOCK_TYPE = 0xeada; export let REACT_FUNDAMENTAL_TYPE = 0xead5; export let REACT_RESPONDER_TYPE = 0xead6; export let REACT_SCOPE_TYPE = 0xead7; +export let REACT_OPAQUE_ID_TYPE = 0xeae0; if (typeof Symbol === 'function' && Symbol.for) { const symbolFor = Symbol.for; @@ -46,6 +47,7 @@ if (typeof Symbol === 'function' && Symbol.for) { REACT_FUNDAMENTAL_TYPE = symbolFor('react.fundamental'); REACT_RESPONDER_TYPE = symbolFor('react.responder'); REACT_SCOPE_TYPE = symbolFor('react.scope'); + REACT_OPAQUE_ID_TYPE = symbolFor('react.opaque.id'); } const MAYBE_ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index e8f9108d92fc..49d179af10be 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -351,5 +351,6 @@ "351": "Unsupported type.", "352": "React Blocks (and Lazy Components) are expected to be replaced by a compiler on the server. Try configuring your compiler set up and avoid using React.lazy inside of Blocks.", "353": "A server block should never encode any other slots. This is a bug in React.", - "354": "getInspectorDataForViewAtPoint() is not available in production." + "354": "getInspectorDataForViewAtPoint() is not available in production.", + "355": "The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly." }