diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 957838ed58a9..eed8c46df7d6 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -341,6 +341,17 @@ function useOpaqueIdentifier(): OpaqueIDType | void { return value; } +function useId(): string { + const hook = nextHook(); + const id = hook !== null ? hook.memoizedState : ''; + hookLog.push({ + primitive: 'Id', + stackError: new Error(), + value: id, + }); + return id; +} + const Dispatcher: DispatcherType = { getCacheForType, readContext, @@ -361,6 +372,7 @@ const Dispatcher: DispatcherType = { useSyncExternalStore, useDeferredValue, useOpaqueIdentifier, + useId, }; // Inspect diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 3a6ac01f9816..d17a01a25827 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -628,7 +628,7 @@ describe('ReactHooksInspectionIntegration', () => { it('should support composite useOpaqueIdentifier hook in concurrent mode', () => { function Foo(props) { const id = React.unstable_useOpaqueIdentifier(); - const [state] = React.useState(() => 'hello', []); + const [state] = React.useState('hello'); return
{state}
; } @@ -656,6 +656,33 @@ describe('ReactHooksInspectionIntegration', () => { }); }); + it('should support useId hook', () => { + function Foo(props) { + const id = React.unstable_useId(); + 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('Id'); + expect(String(tree[0].value).startsWith('r:')).toBe(true); + + expect(tree[1]).toEqual({ + id: 1, + isStateEditable: true, + name: 'State', + value: 'hello', + subHooks: [], + }); + }); + describe('useDebugValue', () => { it('should support inspectable values for multiple custom hooks', () => { function useLabeledValue(label) { diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js new file mode 100644 index 000000000000..b61e79fa670d --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -0,0 +1,515 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +let JSDOM; +let React; +let ReactDOM; +let Scheduler; +let clientAct; +let ReactDOMFizzServer; +let Stream; +let Suspense; +let useId; +let document; +let writable; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; + +describe('useId', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + clientAct = require('jest-react').act; + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + Suspense = React.Suspense; + useId = React.unstable_useId; + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + async function serverAct(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if (node.nodeName === 'SCRIPT') { + const script = document.createElement('script'); + script.textContent = node.textContent; + fakeBody.removeChild(node); + container.appendChild(script); + } else { + container.appendChild(node); + } + } + } + + function normalizeTreeIdForTesting(id) { + const [serverClientPrefix, base32, hookIndex] = id.split(':'); + if (serverClientPrefix === 'r') { + // Client ids aren't stable. For testing purposes, strip out the counter. + return ( + 'CLIENT_GENERATED_ID' + + (hookIndex !== undefined ? ` (${hookIndex})` : '') + ); + } + // Formats the tree id as a binary sequence, so it's easier to visualize + // the structure. + return ( + parseInt(base32, 32).toString(2) + + (hookIndex !== undefined ? ` (${hookIndex})` : '') + ); + } + + function DivWithId({children}) { + const id = normalizeTreeIdForTesting(useId()); + return
{children}
; + } + + test('basic example', async () => { + function App() { + return ( +
+
+ + +
+ +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(container).toMatchInlineSnapshot(` +
+
+
+
+
+
+
+
+
+ `); + }); + + test('indirections', async () => { + function App() { + // There are no forks in this tree, but the parent and the child should + // have different ids. + return ( + +
+
+
+ +
+
+
+
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(container).toMatchInlineSnapshot(` +
+
+
+
+
+
+
+
+
+
+
+ `); + }); + + test('empty (null) children', async () => { + // We don't treat empty children different from non-empty ones, which means + // they get allocated a slot when generating ids. There's no inherent reason + // to do this; Fiber happens to allocate a fiber for null children that + // appear in a list, which is not ideal for performance. For the purposes + // of id generation, though, what matters is that Fizz and Fiber + // are consistent. + function App() { + return ( + <> + {null} + + {null} + + + ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(container).toMatchInlineSnapshot(` +
+
+
+
+ `); + }); + + test('large ids', async () => { + // The component in this test outputs a recursive tree of nodes with ids, + // where the underlying binary representation is an alternating series of 1s + // and 0s. In other words, they are all of the form 101010101. + // + // Because we use base 32 encoding, the resulting id should consist of + // alternating 'a' (01010) and 'l' (10101) characters, except for the the + // 'R:' prefix, and the first character after that, which may not correspond + // to a complete set of 5 bits. + // + // Example: R:clalalalalalalala... + // + // We can use this pattern to test large ids that exceed the bitwise + // safe range (32 bits). The algorithm should theoretically support ids + // of any size. + + function Child({children}) { + const id = useId(); + return
{children}
; + } + + function App() { + let tree = ; + for (let i = 0; i < 50; i++) { + tree = ( + <> + + {tree} + + ); + } + return tree; + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + const divs = container.querySelectorAll('div'); + + // Confirm that every id matches the expected pattern + for (let i = 0; i < divs.length; i++) { + // Example: R:clalalalalalalala... + expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/); + } + }); + + test('multiple ids in a single component', async () => { + function App() { + const id1 = useId(); + const id2 = useId(); + const id3 = useId(); + return `${id1}, ${id2}, ${id3}`; + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + // We append a suffix to the end of the id to distinguish them + expect(container).toMatchInlineSnapshot(` +
+ R:0, R:0:1, R:0:2 + +
+ `); + }); + + test('basic incremental hydration', async () => { + function App() { + return ( +
+ + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(container).toMatchInlineSnapshot(` +
+
+ +
+
+ +
+
+
+ `); + }); + + test('inserting/deleting siblings outside a dehydrated Suspense boundary', async () => { + const span = React.createRef(null); + function App({swap}) { + // Note: Using a dynamic array so these are treated as insertions and + // deletions instead of updates, because Fiber currently allocates a node + // even for empty children. + const children = [ + , + swap ? : , + , + ]; + return ( + <> + {children} + + + + + + ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + const dehydratedSpan = container.getElementsByTagName('span')[0]; + await clientAct(async () => { + const root = ReactDOM.hydrateRoot(container, ); + expect(Scheduler).toFlushUntilNextPaint([]); + expect(container).toMatchInlineSnapshot(` +
+
+
+
+ +
+ + +
+ `); + + // The inner boundary hasn't hydrated yet + expect(span.current).toBe(null); + + // Swap B for C + root.render(); + }); + // The swap should not have caused a mismatch. + expect(container).toMatchInlineSnapshot(` +
+
+
+
+ +
+ + +
+ `); + // Should have hydrated successfully + expect(span.current).toBe(dehydratedSpan); + }); + + test('inserting/deleting siblings inside a dehydrated Suspense boundary', async () => { + const span = React.createRef(null); + function App({swap}) { + // Note: Using a dynamic array so these are treated as insertions and + // deletions instead of updates, because Fiber currently allocates a node + // even for empty children. + const children = [ + , + swap ? : , + , + ]; + return ( + + {children} + + + ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + const dehydratedSpan = container.getElementsByTagName('span')[0]; + await clientAct(async () => { + const root = ReactDOM.hydrateRoot(container, ); + expect(Scheduler).toFlushUntilNextPaint([]); + expect(container).toMatchInlineSnapshot(` +
+ +
+
+
+ + +
+ `); + + // The inner boundary hasn't hydrated yet + expect(span.current).toBe(null); + + // Swap B for C + root.render(); + }); + // The swap should not have caused a mismatch. + expect(container).toMatchInlineSnapshot(` +
+ +
+
+
+ + +
+ `); + // Should have hydrated successfully + expect(span.current).toBe(dehydratedSpan); + }); +}); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 168fd78f6103..26f2dd00ee0c 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -519,6 +519,10 @@ function useOpaqueIdentifier(): OpaqueIDType { ); } +function useId(): OpaqueIDType { + throw new Error('Not implemented.'); +} + function useCacheRefresh(): (?() => T, ?T) => void { throw new Error('Not implemented.'); } @@ -549,6 +553,7 @@ export const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useOpaqueIdentifier, + useId, // Subscriptions are not setup in a server environment. useMutableSource, useSyncExternalStore, diff --git a/packages/react-reconciler/src/ReactChildFiber.new.js b/packages/react-reconciler/src/ReactChildFiber.new.js index 9071edc24f7a..658b1f0e7b79 100644 --- a/packages/react-reconciler/src/ReactChildFiber.new.js +++ b/packages/react-reconciler/src/ReactChildFiber.new.js @@ -13,7 +13,7 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.new'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {Placement, ChildDeletion} from './ReactFiberFlags'; +import {Placement, ChildDeletion, Forked} from './ReactFiberFlags'; import { getIteratorFn, REACT_ELEMENT_TYPE, @@ -40,6 +40,8 @@ import { import {emptyRefsObject} from './ReactFiberClassComponent.new'; import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.new'; import {StrictLegacyMode} from './ReactTypeOfMode'; +import {getIsHydrating} from './ReactFiberHydrationContext.new'; +import {pushTreeFork} from './ReactFiberTreeContext.new'; let didWarnAboutMaps; let didWarnAboutGenerators; @@ -334,7 +336,9 @@ function ChildReconciler(shouldTrackSideEffects) { ): number { newFiber.index = newIndex; if (!shouldTrackSideEffects) { - // Noop. + // During hydration, the useId algorithm needs to know which fibers are + // part of a list of children (arrays, iterators). + newFiber.flags |= Forked; return lastPlacedIndex; } const current = newFiber.alternate; @@ -823,6 +827,10 @@ function ChildReconciler(shouldTrackSideEffects) { if (newIdx === newChildren.length) { // We've reached the end of the new children. We can delete the rest. deleteRemainingChildren(returnFiber, oldFiber); + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -843,6 +851,10 @@ function ChildReconciler(shouldTrackSideEffects) { } previousNewFiber = newFiber; } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -886,6 +898,10 @@ function ChildReconciler(shouldTrackSideEffects) { existingChildren.forEach(child => deleteChild(returnFiber, child)); } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -1013,6 +1029,10 @@ function ChildReconciler(shouldTrackSideEffects) { if (step.done) { // We've reached the end of the new children. We can delete the rest. deleteRemainingChildren(returnFiber, oldFiber); + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -1033,6 +1053,10 @@ function ChildReconciler(shouldTrackSideEffects) { } previousNewFiber = newFiber; } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -1076,6 +1100,10 @@ function ChildReconciler(shouldTrackSideEffects) { existingChildren.forEach(child => deleteChild(returnFiber, child)); } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } diff --git a/packages/react-reconciler/src/ReactChildFiber.old.js b/packages/react-reconciler/src/ReactChildFiber.old.js index 0128ca8f36f3..0ef3b301e95a 100644 --- a/packages/react-reconciler/src/ReactChildFiber.old.js +++ b/packages/react-reconciler/src/ReactChildFiber.old.js @@ -13,7 +13,7 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.old'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {Placement, ChildDeletion} from './ReactFiberFlags'; +import {Placement, ChildDeletion, Forked} from './ReactFiberFlags'; import { getIteratorFn, REACT_ELEMENT_TYPE, @@ -40,6 +40,8 @@ import { import {emptyRefsObject} from './ReactFiberClassComponent.old'; import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.old'; import {StrictLegacyMode} from './ReactTypeOfMode'; +import {getIsHydrating} from './ReactFiberHydrationContext.old'; +import {pushTreeFork} from './ReactFiberTreeContext.old'; let didWarnAboutMaps; let didWarnAboutGenerators; @@ -334,7 +336,9 @@ function ChildReconciler(shouldTrackSideEffects) { ): number { newFiber.index = newIndex; if (!shouldTrackSideEffects) { - // Noop. + // During hydration, the useId algorithm needs to know which fibers are + // part of a list of children (arrays, iterators). + newFiber.flags |= Forked; return lastPlacedIndex; } const current = newFiber.alternate; @@ -823,6 +827,10 @@ function ChildReconciler(shouldTrackSideEffects) { if (newIdx === newChildren.length) { // We've reached the end of the new children. We can delete the rest. deleteRemainingChildren(returnFiber, oldFiber); + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -843,6 +851,10 @@ function ChildReconciler(shouldTrackSideEffects) { } previousNewFiber = newFiber; } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -886,6 +898,10 @@ function ChildReconciler(shouldTrackSideEffects) { existingChildren.forEach(child => deleteChild(returnFiber, child)); } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -1013,6 +1029,10 @@ function ChildReconciler(shouldTrackSideEffects) { if (step.done) { // We've reached the end of the new children. We can delete the rest. deleteRemainingChildren(returnFiber, oldFiber); + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -1033,6 +1053,10 @@ function ChildReconciler(shouldTrackSideEffects) { } previousNewFiber = newFiber; } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } @@ -1076,6 +1100,10 @@ function ChildReconciler(shouldTrackSideEffects) { existingChildren.forEach(child => deleteChild(returnFiber, child)); } + if (getIsHydrating()) { + const numberOfForks = newIdx; + pushTreeFork(returnFiber, numberOfForks); + } return resultingFirstChild; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 4fe648bc3e76..653ee9e1b4ea 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -186,6 +186,7 @@ import { invalidateContextProvider, } from './ReactFiberContext.new'; import { + getIsHydrating, enterHydrationState, reenterHydrationStateFromDehydratedSuspenseInstance, resetHydrationState, @@ -235,6 +236,11 @@ import {createClassErrorUpdate} from './ReactFiberThrow.new'; import {completeSuspendedOffscreenHostContainer} from './ReactFiberCompleteWork.new'; import is from 'shared/objectIs'; import {setIsStrictModeForDevtools} from './ReactFiberDevToolsHook.new'; +import { + getForksAtLevel, + isForkedChild, + pushTreeId, +} from './ReactFiberTreeContext.new'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -1757,6 +1763,7 @@ function mountIndeterminateComponent( } } } + reconcileChildren(null, workInProgress, value, renderLanes); if (__DEV__) { validateFunctionComponentInDev(workInProgress, Component); @@ -1845,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2693,6 +2701,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; @@ -3675,6 +3684,21 @@ function beginWork( } } else { didReceiveUpdate = false; + + if (getIsHydrating() && isForkedChild(workInProgress)) { + // Check if this child belongs to a list of muliple children in + // its parent. + // + // In a true multi-threaded implementation, we would render children on + // parallel threads. This would represent the beginning of a new render + // thread for this subtree. + // + // We only use this for id generation during hydration, which is why the + // logic is located in this special branch. + const slotIndex = workInProgress.index; + const numberOfForks = getForksAtLevel(workInProgress); + pushTreeId(workInProgress, numberOfForks, slotIndex); + } } // Before entering the begin phase, clear pending update priority. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index f116897a8661..9833ef481af7 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -186,6 +186,7 @@ import { invalidateContextProvider, } from './ReactFiberContext.old'; import { + getIsHydrating, enterHydrationState, reenterHydrationStateFromDehydratedSuspenseInstance, resetHydrationState, @@ -235,6 +236,11 @@ import {createClassErrorUpdate} from './ReactFiberThrow.old'; import {completeSuspendedOffscreenHostContainer} from './ReactFiberCompleteWork.old'; import is from 'shared/objectIs'; import {setIsStrictModeForDevtools} from './ReactFiberDevToolsHook.old'; +import { + getForksAtLevel, + isForkedChild, + pushTreeId, +} from './ReactFiberTreeContext.old'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -1757,6 +1763,7 @@ function mountIndeterminateComponent( } } } + reconcileChildren(null, workInProgress, value, renderLanes); if (__DEV__) { validateFunctionComponentInDev(workInProgress, Component); @@ -1845,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2693,6 +2701,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; @@ -3675,6 +3684,21 @@ function beginWork( } } else { didReceiveUpdate = false; + + if (getIsHydrating() && isForkedChild(workInProgress)) { + // Check if this child belongs to a list of muliple children in + // its parent. + // + // In a true multi-threaded implementation, we would render children on + // parallel threads. This would represent the beginning of a new render + // thread for this subtree. + // + // We only use this for id generation during hydration, which is why the + // logic is located in this special branch. + const slotIndex = workInProgress.index; + const numberOfForks = getForksAtLevel(workInProgress); + pushTreeId(workInProgress, numberOfForks, slotIndex); + } } // Before entering the begin phase, clear pending update priority. diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 20a7fc52db13..feb38f00461a 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -155,6 +155,7 @@ import { popRootCachePool, popCachePool, } from './ReactFiberCacheComponent.new'; +import {popTreeContext} from './ReactFiberTreeContext.new'; function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into @@ -822,7 +823,11 @@ function completeWork( renderLanes: Lanes, ): Fiber | null { const newProps = workInProgress.pendingProps; - + // Note: This intentionally doesn't check if we're hydrating because comparing + // to the current tree provider fiber is just as fast and less error-prone. + // Ideally we would have a special version of the work loop only + // for hydration. + popTreeContext(workInProgress); switch (workInProgress.tag) { case IndeterminateComponent: case LazyComponent: diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 305359aef206..ea4d71e8ba37 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -155,6 +155,7 @@ import { popRootCachePool, popCachePool, } from './ReactFiberCacheComponent.old'; +import {popTreeContext} from './ReactFiberTreeContext.old'; function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into @@ -822,7 +823,11 @@ function completeWork( renderLanes: Lanes, ): Fiber | null { const newProps = workInProgress.pendingProps; - + // Note: This intentionally doesn't check if we're hydrating because comparing + // to the current tree provider fiber is just as fast and less error-prone. + // Ideally we would have a special version of the work loop only + // for hydration. + popTreeContext(workInProgress); switch (workInProgress.tag) { case IndeterminateComponent: case LazyComponent: diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index a82278222bf0..805c4bed918e 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,54 +12,55 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b0000000000000000000000000; -export const PerformedWork = /* */ 0b0000000000000000000000001; +export const NoFlags = /* */ 0b00000000000000000000000000; +export const PerformedWork = /* */ 0b00000000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b0000000000000000000000010; -export const Update = /* */ 0b0000000000000000000000100; +export const Placement = /* */ 0b00000000000000000000000010; +export const Update = /* */ 0b00000000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b0000000000000000000001000; -export const ChildDeletion = /* */ 0b0000000000000000000010000; -export const ContentReset = /* */ 0b0000000000000000000100000; -export const Callback = /* */ 0b0000000000000000001000000; -export const DidCapture = /* */ 0b0000000000000000010000000; -export const ForceClientRender = /* */ 0b0000000000000000100000000; -export const Ref = /* */ 0b0000000000000001000000000; -export const Snapshot = /* */ 0b0000000000000010000000000; -export const Passive = /* */ 0b0000000000000100000000000; -export const Hydrating = /* */ 0b0000000000001000000000000; +export const Deletion = /* */ 0b00000000000000000000001000; +export const ChildDeletion = /* */ 0b00000000000000000000010000; +export const ContentReset = /* */ 0b00000000000000000000100000; +export const Callback = /* */ 0b00000000000000000001000000; +export const DidCapture = /* */ 0b00000000000000000010000000; +export const ForceClientRender = /* */ 0b00000000000000000100000000; +export const Ref = /* */ 0b00000000000000001000000000; +export const Snapshot = /* */ 0b00000000000000010000000000; +export const Passive = /* */ 0b00000000000000100000000000; +export const Hydrating = /* */ 0b00000000000001000000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b0000000000010000000000000; -export const StoreConsistency = /* */ 0b0000000000100000000000000; +export const Visibility = /* */ 0b00000000000010000000000000; +export const StoreConsistency = /* */ 0b00000000000100000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot | StoreConsistency; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b0000000000111111111111111; +export const HostEffectMask = /* */ 0b00000000000111111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b0000000001000000000000000; -export const ShouldCapture = /* */ 0b0000000010000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b0000000100000000000000000; -export const DidPropagateContext = /* */ 0b0000001000000000000000000; -export const NeedsPropagation = /* */ 0b0000010000000000000000000; +export const Incomplete = /* */ 0b00000000001000000000000000; +export const ShouldCapture = /* */ 0b00000000010000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b00000000100000000000000000; +export const DidPropagateContext = /* */ 0b00000001000000000000000000; +export const NeedsPropagation = /* */ 0b00000010000000000000000000; +export const Forked = /* */ 0b00000100000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const RefStatic = /* */ 0b0000100000000000000000000; -export const LayoutStatic = /* */ 0b0001000000000000000000000; -export const PassiveStatic = /* */ 0b0010000000000000000000000; +export const RefStatic = /* */ 0b00001000000000000000000000; +export const LayoutStatic = /* */ 0b00010000000000000000000000; +export const PassiveStatic = /* */ 0b00100000000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b0100000000000000000000000; -export const MountPassiveDev = /* */ 0b1000000000000000000000000; +export const MountLayoutDev = /* */ 0b01000000000000000000000000; +export const MountPassiveDev = /* */ 0b10000000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 732f7d71a7e7..a1d7009a85a4 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -117,6 +117,7 @@ import { } from './ReactUpdateQueue.new'; import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new'; import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; +import {getTreeId, pushTreeFork, pushTreeId} from './ReactFiberTreeContext.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -203,6 +204,12 @@ let didScheduleRenderPhaseUpdate: boolean = false; // TODO: Maybe there's some way to consolidate this with // `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`. let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; +// Counts the number of useId hooks in this component. +let localIdCounter: number = 0; +// Used for ids that are generated completely client-side (i.e. not during +// hydration). This counter is global, so client ids are not stable across +// render attempts. +let globalClientIdCounter: number = 0; const RE_RENDER_LIMIT = 25; @@ -396,6 +403,7 @@ export function renderWithHooks( // workInProgressHook = null; // didScheduleRenderPhaseUpdate = false; + // localIdCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -543,6 +551,21 @@ export function renderWithHooks( } } + if (localIdCounter !== 0) { + localIdCounter = 0; + if (getIsHydrating()) { + // This component materialized an id. This will affect any ids that appear + // in its children. + const returnFiber = workInProgress.return; + if (returnFiber !== null) { + const numberOfForks = 1; + const slotIndex = 0; + pushTreeFork(workInProgress, numberOfForks); + pushTreeId(workInProgress, numberOfForks, slotIndex); + } + } + } + return children; } @@ -612,6 +635,7 @@ export function resetHooksAfterThrow(): void { } didScheduleRenderPhaseUpdateDuringThisPass = false; + localIdCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -2109,6 +2133,39 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void { return id; } +function mountId(): string { + const hook = mountWorkInProgressHook(); + + let id; + if (getIsHydrating()) { + const treeId = getTreeId(); + + // Use a captial R prefix for server-generated ids. + id = 'R:' + treeId; + + // Unless this is the first id at this level, append a number at the end + // that represents the position of this useId hook among all the useId + // hooks for this fiber. + const localId = localIdCounter++; + if (localId > 0) { + id += ':' + localId.toString(32); + } + } else { + // Use a lowercase r prefix for client-generated ids. + const globalClientId = globalClientIdCounter++; + id = 'r:' + globalClientId.toString(32); + } + + hook.memoizedState = id; + return id; +} + +function updateId(): string { + const hook = updateWorkInProgressHook(); + const id: string = hook.memoizedState; + return id; +} + function mountRefresh() { const hook = mountWorkInProgressHook(); const refresh = (hook.memoizedState = refreshCache.bind( @@ -2425,6 +2482,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, + useId: throwInvalidHookError, unstable_isNewReconciler: enableNewReconciler, }; @@ -2453,6 +2511,7 @@ const HooksDispatcherOnMount: Dispatcher = { useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, + useId: mountId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2481,6 +2540,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2509,6 +2569,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2680,6 +2741,11 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2822,6 +2888,11 @@ if (__DEV__) { updateHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2964,6 +3035,11 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3107,6 +3183,11 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3266,6 +3347,12 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3425,6 +3512,12 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3585,6 +3678,12 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index b78f24e8b47f..167698271dba 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -117,6 +117,7 @@ import { } from './ReactUpdateQueue.old'; import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.old'; import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; +import {getTreeId, pushTreeFork, pushTreeId} from './ReactFiberTreeContext.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -203,6 +204,12 @@ let didScheduleRenderPhaseUpdate: boolean = false; // TODO: Maybe there's some way to consolidate this with // `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`. let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; +// Counts the number of useId hooks in this component. +let localIdCounter: number = 0; +// Used for ids that are generated completely client-side (i.e. not during +// hydration). This counter is global, so client ids are not stable across +// render attempts. +let globalClientIdCounter: number = 0; const RE_RENDER_LIMIT = 25; @@ -396,6 +403,7 @@ export function renderWithHooks( // workInProgressHook = null; // didScheduleRenderPhaseUpdate = false; + // localIdCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -543,6 +551,21 @@ export function renderWithHooks( } } + if (localIdCounter !== 0) { + localIdCounter = 0; + if (getIsHydrating()) { + // This component materialized an id. This will affect any ids that appear + // in its children. + const returnFiber = workInProgress.return; + if (returnFiber !== null) { + const numberOfForks = 1; + const slotIndex = 0; + pushTreeFork(workInProgress, numberOfForks); + pushTreeId(workInProgress, numberOfForks, slotIndex); + } + } + } + return children; } @@ -612,6 +635,7 @@ export function resetHooksAfterThrow(): void { } didScheduleRenderPhaseUpdateDuringThisPass = false; + localIdCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -2109,6 +2133,39 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void { return id; } +function mountId(): string { + const hook = mountWorkInProgressHook(); + + let id; + if (getIsHydrating()) { + const treeId = getTreeId(); + + // Use a captial R prefix for server-generated ids. + id = 'R:' + treeId; + + // Unless this is the first id at this level, append a number at the end + // that represents the position of this useId hook among all the useId + // hooks for this fiber. + const localId = localIdCounter++; + if (localId > 0) { + id += ':' + localId.toString(32); + } + } else { + // Use a lowercase r prefix for client-generated ids. + const globalClientId = globalClientIdCounter++; + id = 'r:' + globalClientId.toString(32); + } + + hook.memoizedState = id; + return id; +} + +function updateId(): string { + const hook = updateWorkInProgressHook(); + const id: string = hook.memoizedState; + return id; +} + function mountRefresh() { const hook = mountWorkInProgressHook(); const refresh = (hook.memoizedState = refreshCache.bind( @@ -2425,6 +2482,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, + useId: throwInvalidHookError, unstable_isNewReconciler: enableNewReconciler, }; @@ -2453,6 +2511,7 @@ const HooksDispatcherOnMount: Dispatcher = { useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, + useId: mountId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2481,6 +2540,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2509,6 +2569,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2680,6 +2741,11 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2822,6 +2888,11 @@ if (__DEV__) { updateHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2964,6 +3035,11 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3107,6 +3183,11 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3266,6 +3347,12 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3425,6 +3512,12 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3585,6 +3678,12 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 7275f1663cad..eabc5e43116b 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -17,6 +17,7 @@ import type { HostContext, } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; +import type {TreeContext} from './ReactFiberTreeContext.new'; import { HostComponent, @@ -62,6 +63,10 @@ import { } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.new'; +import { + getSuspendedTreeContext, + restoreSuspendedTreeContext, +} from './ReactFiberTreeContext.new'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean { function reenterHydrationStateFromDehydratedSuspenseInstance( fiber: Fiber, suspenseInstance: SuspenseInstance, + treeContext: TreeContext | null, ): boolean { if (!supportsHydration) { return false; @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( ); hydrationParentFiber = fiber; isHydrating = true; + if (treeContext !== null) { + restoreSuspendedTreeContext(fiber, treeContext); + } return true; } @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) { if (suspenseInstance !== null) { const suspenseState: SuspenseState = { dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, }; fiber.memoizedState = suspenseState; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 654de3f9a289..48e60581e0f2 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -17,6 +17,7 @@ import type { HostContext, } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; +import type {TreeContext} from './ReactFiberTreeContext.old'; import { HostComponent, @@ -62,6 +63,10 @@ import { } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.old'; +import { + getSuspendedTreeContext, + restoreSuspendedTreeContext, +} from './ReactFiberTreeContext.old'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean { function reenterHydrationStateFromDehydratedSuspenseInstance( fiber: Fiber, suspenseInstance: SuspenseInstance, + treeContext: TreeContext | null, ): boolean { if (!supportsHydration) { return false; @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( ); hydrationParentFiber = fiber; isHydrating = true; + if (treeContext !== null) { + restoreSuspendedTreeContext(fiber, treeContext); + } return true; } @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) { if (suspenseInstance !== null) { const suspenseState: SuspenseState = { dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, }; fiber.memoizedState = suspenseState; diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index c1f34e1052fc..7e1461c7a722 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -23,6 +23,7 @@ import { } from 'shared/ReactFeatureFlags'; import {isDevToolsPresent} from './ReactFiberDevToolsHook.new'; import {ConcurrentUpdatesByDefaultMode, NoMode} from './ReactTypeOfMode'; +import {clz32} from './clz32'; // Lane values below should be kept in sync with getLabelForLane(), used by react-devtools-scheduling-profiler. // If those values are changed that package should be rebuilt and redeployed. @@ -791,17 +792,3 @@ export function movePendingFibersToMemoized(root: FiberRoot, lanes: Lanes) { lanes &= ~lane; } } - -const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback; - -// Count leading zeros. Only used on lanes, so assume input is an integer. -// Based on: -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 -const log = Math.log; -const LN2 = Math.LN2; -function clz32Fallback(lanes: Lanes | Lane) { - if (lanes === 0) { - return 32; - } - return (31 - ((log(lanes) / LN2) | 0)) | 0; -} diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js index c81191f6a07e..6b4be15e649f 100644 --- a/packages/react-reconciler/src/ReactFiberLane.old.js +++ b/packages/react-reconciler/src/ReactFiberLane.old.js @@ -23,6 +23,7 @@ import { } from 'shared/ReactFeatureFlags'; import {isDevToolsPresent} from './ReactFiberDevToolsHook.old'; import {ConcurrentUpdatesByDefaultMode, NoMode} from './ReactTypeOfMode'; +import {clz32} from './clz32'; // Lane values below should be kept in sync with getLabelForLane(), used by react-devtools-scheduling-profiler. // If those values are changed that package should be rebuilt and redeployed. @@ -791,17 +792,3 @@ export function movePendingFibersToMemoized(root: FiberRoot, lanes: Lanes) { lanes &= ~lane; } } - -const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback; - -// Count leading zeros. Only used on lanes, so assume input is an integer. -// Based on: -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 -const log = Math.log; -const LN2 = Math.LN2; -function clz32Fallback(lanes: Lanes | Lane) { - if (lanes === 0) { - return 32; - } - return (31 - ((log(lanes) / LN2) | 0)) | 0; -} diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index 5ad7ae650249..9dbaf7fb76ef 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.new'; +import type {TreeContext} from './ReactFiberTreeContext.new'; + import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -40,6 +42,7 @@ export type SuspenseState = {| // here to indicate that it is dehydrated (flag) and for quick access // to check things like isSuspenseInstancePending. dehydrated: null | SuspenseInstance, + treeContext: null | TreeContext, // Represents the lane we should attempt to hydrate a dehydrated boundary at. // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index 51bef1df3a56..726f0ca52005 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.old'; +import type {TreeContext} from './ReactFiberTreeContext.old'; + import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -40,6 +42,7 @@ export type SuspenseState = {| // here to indicate that it is dehydrated (flag) and for quick access // to check things like isSuspenseInstancePending. dehydrated: null | SuspenseInstance, + treeContext: null | TreeContext, // Represents the lane we should attempt to hydrate a dehydrated boundary at. // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.new.js b/packages/react-reconciler/src/ReactFiberTreeContext.new.js new file mode 100644 index 000000000000..0725ba577e64 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberTreeContext.new.js @@ -0,0 +1,273 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Ids are base 32 strings whose binary representation corresponds to the +// position of a node in a tree. + +// Every time the tree forks into multiple children, we add additional bits to +// the left of the sequence that represent the position of the child within the +// current level of children. +// +// 00101 00010001011010101 +// ╰─┬─╯ ╰───────┬───────╯ +// Fork 5 of 20 Parent id +// +// The leading 0s are important. In the above example, you only need 3 bits to +// represent slot 5. However, you need 5 bits to represent all the forks at +// the current level, so we must account for the empty bits at the end. +// +// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise, +// the zeroth id at a level would be indistinguishable from its parent. +// +// If a node has only one child, and does not materialize an id (i.e. does not +// contain a useId hook), then we don't need to allocate any space in the +// sequence. It's treated as a transparent indirection. For example, these two +// trees produce the same ids: +// +// <> <> +// +// +// +// +// +// +// However, we cannot skip any node that materializes an id. Otherwise, a parent +// id that does not fork would be indistinguishable from its child id. For +// example, this tree does not fork, but the parent and child must have +// different ids. +// +// +// +// +// +// To handle this scenario, every time we materialize an id, we allocate a +// new level with a single slot. You can think of this as a fork with only one +// prong, or an array of children with length 1. +// +// It's possible for the the size of the sequence to exceed 32 bits, the max +// size for bitwise operations. When this happens, we make more room by +// converting the right part of the id to a string and storing it in an overflow +// variable. We use a base 32 string representation, because 32 is the largest +// power of 2 that is supported by toString(). We want the base to be large so +// that the resulting ids are compact, and we want the base to be a power of 2 +// because every log2(base) bits corresponds to a single character, i.e. every +// log2(32) = 5 bits. That means we can lop bits off the end 5 at a time without +// affecting the final result. + +import {getIsHydrating} from './ReactFiberHydrationContext.new'; +import {clz32} from './clz32'; +import {Forked, NoFlags} from './ReactFiberFlags'; + +export type TreeContext = { + id: number, + overflow: string, +}; + +// TODO: Use the unified fiber stack module instead of this local one? +// Intentionally not using it yet to derisk the initial implementation, because +// the way we push/pop these values is a bit unusual. If there's a mistake, I'd +// rather the ids be wrong than crash the whole reconciler. +const forkStack: Array = []; +let forkStackIndex: number = 0; +let treeForkProvider: Fiber | null = null; +let treeForkCount: number = 0; + +const idStack: Array = []; +let idStackIndex: number = 0; +let treeContextProvider: Fiber | null = null; +let treeContextId: number = 1; +let treeContextOverflow: string = ''; + +export function isForkedChild(workInProgress: Fiber): boolean { + warnIfNotHydrating(); + return (workInProgress.flags & Forked) !== NoFlags; +} + +export function getForksAtLevel(workInProgress: Fiber): number { + warnIfNotHydrating(); + return treeForkCount; +} + +export function getTreeId(): string { + const overflow = treeContextOverflow; + const idWithLeadingBit = treeContextId; + const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit); + return id.toString(32) + overflow; +} + +export function pushTreeFork( + workInProgress: Fiber, + totalChildren: number, +): void { + // This is called right after we reconcile an array (or iterator) of child + // fibers, because that's the only place where we know how many children in + // the whole set without doing extra work later, or storing addtional + // information on the fiber. + // + // That's why this function is separate from pushTreeId — it's called during + // the render phase of the fork parent, not the child, which is where we push + // the other context values. + // + // In the Fizz implementation this is much simpler because the child is + // rendered in the same callstack as the parent. + // + // It might be better to just add a `forks` field to the Fiber type. It would + // make this module simpler. + + warnIfNotHydrating(); + + forkStack[forkStackIndex++] = treeForkCount; + forkStack[forkStackIndex++] = treeForkProvider; + + treeForkProvider = workInProgress; + treeForkCount = totalChildren; +} + +export function pushTreeId( + workInProgress: Fiber, + totalChildren: number, + index: number, +) { + warnIfNotHydrating(); + + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + + treeContextProvider = workInProgress; + + const baseIdWithLeadingBit = treeContextId; + const baseOverflow = treeContextOverflow; + + // The leftmost 1 marks the end of the sequence, non-inclusive. It's not part + // of the id; we use it to account for leading 0s. + const baseLength = getBitLength(baseIdWithLeadingBit) - 1; + const baseId = baseIdWithLeadingBit & ~(1 << baseLength); + + const slot = index + 1; + const length = getBitLength(totalChildren) + baseLength; + + // 30 is the max length we can store without overflowing, taking into + // consideration the leading 1 we use to mark the end of the sequence. + if (length > 30) { + // We overflowed the bitwise-safe range. Fall back to slower algorithm. + // This branch assumes the length of the base id is greater than 5; it won't + // work for smaller ids, because you need 5 bits per character. + // + // We encode the id in multiple steps: first the base id, then the + // remaining digits. + // + // Each 5 bit sequence corresponds to a single base 32 character. So for + // example, if the current id is 23 bits long, we can convert 20 of those + // bits into a string of 4 characters, with 3 bits left over. + // + // First calculate how many bits in the base id represent a complete + // sequence of characters. + const numberOfOverflowBits = baseLength - (baseLength % 5); + + // Then create a bitmask that selects only those bits. + const newOverflowBits = (1 << numberOfOverflowBits) - 1; + + // Select the bits, and convert them to a base 32 string. + const newOverflow = (baseId & newOverflowBits).toString(32); + + // Now we can remove those bits from the base id. + const restOfBaseId = baseId >> numberOfOverflowBits; + const restOfBaseLength = baseLength - numberOfOverflowBits; + + // Finally, encode the rest of the bits using the normal algorithm. Because + // we made more room, this time it won't overflow. + const restOfLength = getBitLength(totalChildren) + restOfBaseLength; + const restOfNewBits = slot << restOfBaseLength; + const id = restOfNewBits | restOfBaseId; + const overflow = newOverflow + baseOverflow; + + treeContextId = (1 << restOfLength) | id; + treeContextOverflow = overflow; + } else { + // Normal path + const newBits = slot << baseLength; + const id = newBits | baseId; + const overflow = baseOverflow; + + treeContextId = (1 << length) | id; + treeContextOverflow = overflow; + } +} + +function getBitLength(number: number): number { + return 32 - clz32(number); +} + +function getLeadingBit(id: number) { + return 1 << (getBitLength(id) - 1); +} + +export function popTreeContext(workInProgress: Fiber) { + // Restore the previous values. + + // This is a bit more complicated than other context-like modules in Fiber + // because the same Fiber may appear on the stack multiple times and for + // different reasons. We have to keep popping until the work-in-progress is + // no longer at the top of the stack. + + while (workInProgress === treeForkProvider) { + treeForkProvider = forkStack[--forkStackIndex]; + forkStack[forkStackIndex] = null; + treeForkCount = forkStack[--forkStackIndex]; + forkStack[forkStackIndex] = null; + } + + while (workInProgress === treeContextProvider) { + treeContextProvider = idStack[--idStackIndex]; + idStack[idStackIndex] = null; + treeContextOverflow = idStack[--idStackIndex]; + idStack[idStackIndex] = null; + treeContextId = idStack[--idStackIndex]; + idStack[idStackIndex] = null; + } +} + +export function getSuspendedTreeContext(): TreeContext | null { + warnIfNotHydrating(); + if (treeContextProvider !== null) { + return { + id: treeContextId, + overflow: treeContextOverflow, + }; + } else { + return null; + } +} + +export function restoreSuspendedTreeContext( + workInProgress: Fiber, + suspendedContext: TreeContext, +) { + warnIfNotHydrating(); + + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + + treeContextId = suspendedContext.id; + treeContextOverflow = suspendedContext.overflow; + treeContextProvider = workInProgress; +} + +function warnIfNotHydrating() { + if (__DEV__) { + if (!getIsHydrating()) { + console.error( + 'Expected to be hydrating. This is a bug in React. Please file ' + + 'an issue.', + ); + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.old.js b/packages/react-reconciler/src/ReactFiberTreeContext.old.js new file mode 100644 index 000000000000..a4ba3c3ddb93 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberTreeContext.old.js @@ -0,0 +1,273 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Ids are base 32 strings whose binary representation corresponds to the +// position of a node in a tree. + +// Every time the tree forks into multiple children, we add additional bits to +// the left of the sequence that represent the position of the child within the +// current level of children. +// +// 00101 00010001011010101 +// ╰─┬─╯ ╰───────┬───────╯ +// Fork 5 of 20 Parent id +// +// The leading 0s are important. In the above example, you only need 3 bits to +// represent slot 5. However, you need 5 bits to represent all the forks at +// the current level, so we must account for the empty bits at the end. +// +// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise, +// the zeroth id at a level would be indistinguishable from its parent. +// +// If a node has only one child, and does not materialize an id (i.e. does not +// contain a useId hook), then we don't need to allocate any space in the +// sequence. It's treated as a transparent indirection. For example, these two +// trees produce the same ids: +// +// <> <> +// +// +// +// +// +// +// However, we cannot skip any node that materializes an id. Otherwise, a parent +// id that does not fork would be indistinguishable from its child id. For +// example, this tree does not fork, but the parent and child must have +// different ids. +// +// +// +// +// +// To handle this scenario, every time we materialize an id, we allocate a +// new level with a single slot. You can think of this as a fork with only one +// prong, or an array of children with length 1. +// +// It's possible for the the size of the sequence to exceed 32 bits, the max +// size for bitwise operations. When this happens, we make more room by +// converting the right part of the id to a string and storing it in an overflow +// variable. We use a base 32 string representation, because 32 is the largest +// power of 2 that is supported by toString(). We want the base to be large so +// that the resulting ids are compact, and we want the base to be a power of 2 +// because every log2(base) bits corresponds to a single character, i.e. every +// log2(32) = 5 bits. That means we can lop bits off the end 5 at a time without +// affecting the final result. + +import {getIsHydrating} from './ReactFiberHydrationContext.old'; +import {clz32} from './clz32'; +import {Forked, NoFlags} from './ReactFiberFlags'; + +export type TreeContext = { + id: number, + overflow: string, +}; + +// TODO: Use the unified fiber stack module instead of this local one? +// Intentionally not using it yet to derisk the initial implementation, because +// the way we push/pop these values is a bit unusual. If there's a mistake, I'd +// rather the ids be wrong than crash the whole reconciler. +const forkStack: Array = []; +let forkStackIndex: number = 0; +let treeForkProvider: Fiber | null = null; +let treeForkCount: number = 0; + +const idStack: Array = []; +let idStackIndex: number = 0; +let treeContextProvider: Fiber | null = null; +let treeContextId: number = 1; +let treeContextOverflow: string = ''; + +export function isForkedChild(workInProgress: Fiber): boolean { + warnIfNotHydrating(); + return (workInProgress.flags & Forked) !== NoFlags; +} + +export function getForksAtLevel(workInProgress: Fiber): number { + warnIfNotHydrating(); + return treeForkCount; +} + +export function getTreeId(): string { + const overflow = treeContextOverflow; + const idWithLeadingBit = treeContextId; + const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit); + return id.toString(32) + overflow; +} + +export function pushTreeFork( + workInProgress: Fiber, + totalChildren: number, +): void { + // This is called right after we reconcile an array (or iterator) of child + // fibers, because that's the only place where we know how many children in + // the whole set without doing extra work later, or storing addtional + // information on the fiber. + // + // That's why this function is separate from pushTreeId — it's called during + // the render phase of the fork parent, not the child, which is where we push + // the other context values. + // + // In the Fizz implementation this is much simpler because the child is + // rendered in the same callstack as the parent. + // + // It might be better to just add a `forks` field to the Fiber type. It would + // make this module simpler. + + warnIfNotHydrating(); + + forkStack[forkStackIndex++] = treeForkCount; + forkStack[forkStackIndex++] = treeForkProvider; + + treeForkProvider = workInProgress; + treeForkCount = totalChildren; +} + +export function pushTreeId( + workInProgress: Fiber, + totalChildren: number, + index: number, +) { + warnIfNotHydrating(); + + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + + treeContextProvider = workInProgress; + + const baseIdWithLeadingBit = treeContextId; + const baseOverflow = treeContextOverflow; + + // The leftmost 1 marks the end of the sequence, non-inclusive. It's not part + // of the id; we use it to account for leading 0s. + const baseLength = getBitLength(baseIdWithLeadingBit) - 1; + const baseId = baseIdWithLeadingBit & ~(1 << baseLength); + + const slot = index + 1; + const length = getBitLength(totalChildren) + baseLength; + + // 30 is the max length we can store without overflowing, taking into + // consideration the leading 1 we use to mark the end of the sequence. + if (length > 30) { + // We overflowed the bitwise-safe range. Fall back to slower algorithm. + // This branch assumes the length of the base id is greater than 5; it won't + // work for smaller ids, because you need 5 bits per character. + // + // We encode the id in multiple steps: first the base id, then the + // remaining digits. + // + // Each 5 bit sequence corresponds to a single base 32 character. So for + // example, if the current id is 23 bits long, we can convert 20 of those + // bits into a string of 4 characters, with 3 bits left over. + // + // First calculate how many bits in the base id represent a complete + // sequence of characters. + const numberOfOverflowBits = baseLength - (baseLength % 5); + + // Then create a bitmask that selects only those bits. + const newOverflowBits = (1 << numberOfOverflowBits) - 1; + + // Select the bits, and convert them to a base 32 string. + const newOverflow = (baseId & newOverflowBits).toString(32); + + // Now we can remove those bits from the base id. + const restOfBaseId = baseId >> numberOfOverflowBits; + const restOfBaseLength = baseLength - numberOfOverflowBits; + + // Finally, encode the rest of the bits using the normal algorithm. Because + // we made more room, this time it won't overflow. + const restOfLength = getBitLength(totalChildren) + restOfBaseLength; + const restOfNewBits = slot << restOfBaseLength; + const id = restOfNewBits | restOfBaseId; + const overflow = newOverflow + baseOverflow; + + treeContextId = (1 << restOfLength) | id; + treeContextOverflow = overflow; + } else { + // Normal path + const newBits = slot << baseLength; + const id = newBits | baseId; + const overflow = baseOverflow; + + treeContextId = (1 << length) | id; + treeContextOverflow = overflow; + } +} + +function getBitLength(number: number): number { + return 32 - clz32(number); +} + +function getLeadingBit(id: number) { + return 1 << (getBitLength(id) - 1); +} + +export function popTreeContext(workInProgress: Fiber) { + // Restore the previous values. + + // This is a bit more complicated than other context-like modules in Fiber + // because the same Fiber may appear on the stack multiple times and for + // different reasons. We have to keep popping until the work-in-progress is + // no longer at the top of the stack. + + while (workInProgress === treeForkProvider) { + treeForkProvider = forkStack[--forkStackIndex]; + forkStack[forkStackIndex] = null; + treeForkCount = forkStack[--forkStackIndex]; + forkStack[forkStackIndex] = null; + } + + while (workInProgress === treeContextProvider) { + treeContextProvider = idStack[--idStackIndex]; + idStack[idStackIndex] = null; + treeContextOverflow = idStack[--idStackIndex]; + idStack[idStackIndex] = null; + treeContextId = idStack[--idStackIndex]; + idStack[idStackIndex] = null; + } +} + +export function getSuspendedTreeContext(): TreeContext | null { + warnIfNotHydrating(); + if (treeContextProvider !== null) { + return { + id: treeContextId, + overflow: treeContextOverflow, + }; + } else { + return null; + } +} + +export function restoreSuspendedTreeContext( + workInProgress: Fiber, + suspendedContext: TreeContext, +) { + warnIfNotHydrating(); + + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + + treeContextId = suspendedContext.id; + treeContextOverflow = suspendedContext.overflow; + treeContextProvider = workInProgress; +} + +function warnIfNotHydrating() { + if (__DEV__) { + if (!getIsHydrating()) { + console.error( + 'Expected to be hydrating. This is a bug in React. Please file ' + + 'an issue.', + ); + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js index ba8bd4b57395..bb002b9e71b3 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js @@ -50,8 +50,14 @@ import { popCachePool, } from './ReactFiberCacheComponent.new'; import {transferActualDuration} from './ReactProfilerTimer.new'; +import {popTreeContext} from './ReactFiberTreeContext.new'; function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { + // Note: This intentionally doesn't check if we're hydrating because comparing + // to the current tree provider fiber is just as fast and less error-prone. + // Ideally we would have a special version of the work loop only + // for hydration. + popTreeContext(workInProgress); switch (workInProgress.tag) { case ClassComponent: { const Component = workInProgress.type; @@ -164,6 +170,11 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { } function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { + // Note: This intentionally doesn't check if we're hydrating because comparing + // to the current tree provider fiber is just as fast and less error-prone. + // Ideally we would have a special version of the work loop only + // for hydration. + popTreeContext(interruptedWork); switch (interruptedWork.tag) { case ClassComponent: { const childContextTypes = interruptedWork.type.childContextTypes; diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js index ad8e479700db..7f161513a4af 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js @@ -50,8 +50,14 @@ import { popCachePool, } from './ReactFiberCacheComponent.old'; import {transferActualDuration} from './ReactProfilerTimer.old'; +import {popTreeContext} from './ReactFiberTreeContext.old'; function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { + // Note: This intentionally doesn't check if we're hydrating because comparing + // to the current tree provider fiber is just as fast and less error-prone. + // Ideally we would have a special version of the work loop only + // for hydration. + popTreeContext(workInProgress); switch (workInProgress.tag) { case ClassComponent: { const Component = workInProgress.type; @@ -164,6 +170,11 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { } function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { + // Note: This intentionally doesn't check if we're hydrating because comparing + // to the current tree provider fiber is just as fast and less error-prone. + // Ideally we would have a special version of the work loop only + // for hydration. + popTreeContext(interruptedWork); switch (interruptedWork.tag) { case ClassComponent: { const childContextTypes = interruptedWork.type.childContextTypes; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index e971828233c1..9dac4f40aad5 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -44,6 +44,7 @@ export type HookType = | 'useMutableSource' | 'useSyncExternalStore' | 'useOpaqueIdentifier' + | 'useId' | 'useCacheRefresh'; export type ContextDependency = { @@ -317,6 +318,7 @@ export type Dispatcher = {| getServerSnapshot?: () => T, ): T, useOpaqueIdentifier(): any, + useId(): string, useCacheRefresh?: () => (?() => T, ?T) => void, unstable_isNewReconciler?: boolean, diff --git a/packages/react-reconciler/src/clz32.js b/packages/react-reconciler/src/clz32.js new file mode 100644 index 000000000000..80a9cfb91148 --- /dev/null +++ b/packages/react-reconciler/src/clz32.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// TODO: This is pretty well supported by browsers. Maybe we can drop it. + +export const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback; + +// Count leading zeros. +// Based on: +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 +const log = Math.log; +const LN2 = Math.LN2; +function clz32Fallback(x: number): number { + const asUint = x >>> 0; + if (asUint === 0) { + return 32; + } + return (31 - ((log(asUint) / LN2) | 0)) | 0; +} diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 2596632652b4..bf1a2d56e0f9 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -17,8 +17,10 @@ import type { } from 'shared/ReactTypes'; import type {ResponseState, OpaqueIDType} from './ReactServerFormatConfig'; +import type {Task} from './ReactFizzServer'; import {readContext as readContextImpl} from './ReactFizzNewContext'; +import {getTreeId} from './ReactFizzTreeContext'; import {makeServerID} from './ReactServerFormatConfig'; @@ -45,12 +47,15 @@ type Hook = {| |}; let currentlyRenderingComponent: Object | null = null; +let currentlyRenderingTask: Task | null = null; let firstWorkInProgressHook: Hook | null = null; let workInProgressHook: Hook | null = null; // Whether the work-in-progress hook is a re-rendered hook let isReRender: boolean = false; // Whether an update was scheduled during the currently executing render pass. let didScheduleRenderPhaseUpdate: boolean = false; +// Counts the number of useId hooks in this component +let localIdCounter: number = 0; // Lazily created map of render-phase updates let renderPhaseUpdates: Map, Update> | null = null; // Counter to prevent infinite loops. @@ -163,18 +168,22 @@ function createWorkInProgressHook(): Hook { return workInProgressHook; } -export function prepareToUseHooks(componentIdentity: Object): void { +export function prepareToUseHooks(task: Task, componentIdentity: Object): void { currentlyRenderingComponent = componentIdentity; + currentlyRenderingTask = task; if (__DEV__) { isInHookUserCodeInDev = false; } // The following should have already been reset // didScheduleRenderPhaseUpdate = false; + // localIdCounter = 0; // firstWorkInProgressHook = null; // numberOfReRenders = 0; // renderPhaseUpdates = null; // workInProgressHook = null; + + localIdCounter = 0; } export function finishHooks( @@ -203,6 +212,14 @@ export function finishHooks( return children; } +export function checkDidRenderIdHook() { + // This should be called immediately after every finishHooks call. + // Conceptually, it's part of the return value of finishHooks; it's only a + // separate function to avoid using an array tuple. + const didRenderIdHook = localIdCounter !== 0; + return didRenderIdHook; +} + // Reset the internal hooks state if an error occurs while rendering a component export function resetHooksState(): void { if (__DEV__) { @@ -210,6 +227,7 @@ export function resetHooksState(): void { } currentlyRenderingComponent = null; + currentlyRenderingTask = null; didScheduleRenderPhaseUpdate = false; firstWorkInProgressHook = null; numberOfReRenders = 0; @@ -495,6 +513,24 @@ function useOpaqueIdentifier(): OpaqueIDType { return makeServerID(currentResponseState); } +function useId(): string { + const task: Task = (currentlyRenderingTask: any); + const treeId = getTreeId(task.treeContext); + + // Use a captial R prefix for server-generated ids. + let id = 'R:' + treeId; + + // Unless this is the first id at this level, append a number at the end + // that represents the position of this useId hook among all the useId + // hooks for this fiber. + const localId = localIdCounter++; + if (localId > 0) { + id += ':' + localId.toString(32); + } + + return id; +} + function unsupportedRefresh() { throw new Error('Cache cannot be refreshed during server rendering.'); } @@ -524,6 +560,7 @@ export const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useOpaqueIdentifier, + useId, // Subscriptions are not setup in a server environment. useMutableSource, useSyncExternalStore, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 6692b9264864..f06cbc8fb61a 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -25,6 +25,7 @@ import type { } from './ReactServerFormatConfig'; import type {ContextSnapshot} from './ReactFizzNewContext'; import type {ComponentStackNode} from './ReactFizzComponentStack'; +import type {TreeContext} from './ReactFizzTreeContext'; import { scheduleWork, @@ -78,12 +79,14 @@ import { import { prepareToUseHooks, finishHooks, + checkDidRenderIdHook, resetHooksState, Dispatcher, currentResponseState, setCurrentResponseState, } from './ReactFizzHooks'; import {getStackByComponentStackNode} from './ReactFizzComponentStack'; +import {emptyTreeContext, pushTreeContext} from './ReactFizzTreeContext'; import { getIteratorFn, @@ -134,7 +137,7 @@ type SuspenseBoundary = { fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. }; -type Task = { +export type Task = { node: ReactNodeList, ping: () => void, blockedBoundary: Root | SuspenseBoundary, @@ -142,6 +145,7 @@ type Task = { abortSet: Set, // the abortable set that this task belongs to legacyContext: LegacyContext, // the current legacy context that this task is executing in context: ContextSnapshot, // the current new context that this task is executing in + treeContext: TreeContext, // the current tree context that this task is executing in componentStack: null | ComponentStackNode, // DEV-only component stack }; @@ -265,6 +269,7 @@ export function createRequest( abortSet, emptyContextObject, rootContextSnapshot, + emptyTreeContext, ); pingedTasks.push(rootTask); return request; @@ -302,6 +307,7 @@ function createTask( abortSet: Set, legacyContext: LegacyContext, context: ContextSnapshot, + treeContext: TreeContext, ): Task { request.allPendingTasks++; if (blockedBoundary === null) { @@ -317,6 +323,7 @@ function createTask( abortSet, legacyContext, context, + treeContext, }: any); if (__DEV__) { task.componentStack = null; @@ -497,6 +504,7 @@ function renderSuspenseBoundary( fallbackAbortSet, task.legacyContext, task.context, + task.treeContext, ); if (__DEV__) { suspendedFallbackTask.componentStack = task.componentStack; @@ -564,7 +572,7 @@ function renderWithHooks( secondArg: SecondArg, ): any { const componentIdentity = {}; - prepareToUseHooks(componentIdentity); + prepareToUseHooks(task, componentIdentity); const result = Component(props, secondArg); return finishHooks(Component, props, result, secondArg); } @@ -671,6 +679,7 @@ function renderIndeterminateComponent( } const value = renderWithHooks(request, task, Component, props, legacyContext); + const hasId = checkDidRenderIdHook(); if (__DEV__) { // Support for module components is deprecated and is removed behind a flag. @@ -742,7 +751,21 @@ function renderIndeterminateComponent( } // We're now successfully past this task, and we don't have to pop back to // the previous task every again, so we can use the destructive recursive form. - renderNodeDestructive(request, task, value); + if (hasId) { + // This component materialized an id. We treat this as its own level, with + // a single "child" slot. + const prevTreeContext = task.treeContext; + const totalChildren = 1; + const index = 0; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index); + try { + renderNodeDestructive(request, task, value); + } finally { + task.treeContext = prevTreeContext; + } + } else { + renderNodeDestructive(request, task, value); + } } popComponentStackInDEV(task); } @@ -827,7 +850,22 @@ function renderForwardRef( ): void { pushFunctionComponentStackInDEV(task, type.render); const children = renderWithHooks(request, task, type.render, props, ref); - renderNodeDestructive(request, task, children); + const hasId = checkDidRenderIdHook(); + if (hasId) { + // This component materialized an id. We treat this as its own level, with + // a single "child" slot. + const prevTreeContext = task.treeContext; + const totalChildren = 1; + const index = 0; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index); + try { + renderNodeDestructive(request, task, children); + } finally { + task.treeContext = prevTreeContext; + } + } else { + renderNodeDestructive(request, task, children); + } popComponentStackInDEV(task); } @@ -1122,12 +1160,7 @@ function renderNodeDestructive( } if (isArray(node)) { - for (let i = 0; i < node.length; i++) { - // Recursively render the rest. We need to use the non-destructive form - // so that we can safely pop back up and render the sibling if something - // suspends. - renderNode(request, task, node[i]); - } + renderChildrenArray(request, task, node); return; } @@ -1138,18 +1171,23 @@ function renderNodeDestructive( } const iterator = iteratorFn.call(node); if (iterator) { + // We need to know how many total children are in this set, so that we + // can allocate enough id slots to acommodate them. So we must exhaust + // the iterator before we start recursively rendering the children. + // TODO: This is not great but I think it's inherent to the id + // generation algorithm. let step = iterator.next(); // If there are not entries, we need to push an empty so we start by checking that. if (!step.done) { + const children = []; do { - // Recursively render the rest. We need to use the non-destructive form - // so that we can safely pop back up and render the sibling if something - // suspends. - renderNode(request, task, step.value); + children.push(step.value); step = iterator.next(); } while (!step.done); + renderChildrenArray(request, task, children); return; } + return; } } @@ -1191,6 +1229,21 @@ function renderNodeDestructive( } } +function renderChildrenArray(request, task, children) { + const totalChildren = children.length; + for (let i = 0; i < totalChildren; i++) { + const prevTreeContext = task.treeContext; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); + try { + // We need to use the non-destructive form so that we can safely pop back + // up and render the sibling if something suspends. + renderNode(request, task, children[i]); + } finally { + task.treeContext = prevTreeContext; + } + } +} + function spawnNewSuspendedTask( request: Request, task: Task, @@ -1214,6 +1267,7 @@ function spawnNewSuspendedTask( task.abortSet, task.legacyContext, task.context, + task.treeContext, ); if (__DEV__) { if (task.componentStack !== null) { @@ -1257,6 +1311,7 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void { if (__DEV__) { task.componentStack = previousComponentStack; } + return; } else { // Restore the context. We assume that this will be restored by the inner // functions in case nothing throws so we don't use "finally" here. diff --git a/packages/react-server/src/ReactFizzTreeContext.js b/packages/react-server/src/ReactFizzTreeContext.js new file mode 100644 index 000000000000..c9a47e5af72a --- /dev/null +++ b/packages/react-server/src/ReactFizzTreeContext.js @@ -0,0 +1,168 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Ids are base 32 strings whose binary representation corresponds to the +// position of a node in a tree. + +// Every time the tree forks into multiple children, we add additional bits to +// the left of the sequence that represent the position of the child within the +// current level of children. +// +// 00101 00010001011010101 +// ╰─┬─╯ ╰───────┬───────╯ +// Fork 5 of 20 Parent id +// +// The leading 0s are important. In the above example, you only need 3 bits to +// represent slot 5. However, you need 5 bits to represent all the forks at +// the current level, so we must account for the empty bits at the end. +// +// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise, +// the zeroth id at a level would be indistinguishable from its parent. +// +// If a node has only one child, and does not materialize an id (i.e. does not +// contain a useId hook), then we don't need to allocate any space in the +// sequence. It's treated as a transparent indirection. For example, these two +// trees produce the same ids: +// +// <> <> +// +// +// +// +// +// +// However, we cannot skip any node that materializes an id. Otherwise, a parent +// id that does not fork would be indistinguishable from its child id. For +// example, this tree does not fork, but the parent and child must have +// different ids. +// +// +// +// +// +// To handle this scenario, every time we materialize an id, we allocate a +// new level with a single slot. You can think of this as a fork with only one +// prong, or an array of children with length 1. +// +// It's possible for the the size of the sequence to exceed 32 bits, the max +// size for bitwise operations. When this happens, we make more room by +// converting the right part of the id to a string and storing it in an overflow +// variable. We use a base 32 string representation, because 32 is the largest +// power of 2 that is supported by toString(). We want the base to be large so +// that the resulting ids are compact, and we want the base to be a power of 2 +// because every log2(base) bits corresponds to a single character, i.e. every +// log2(32) = 5 bits. That means we can lop bits off the end 5 at a time without +// affecting the final result. + +export type TreeContext = { + +id: number, + +overflow: string, +}; + +export const emptyTreeContext = { + id: 1, + overflow: '', +}; + +export function getTreeId(context: TreeContext): string { + const overflow = context.overflow; + const idWithLeadingBit = context.id; + const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit); + return id.toString(32) + overflow; +} + +export function pushTreeContext( + baseContext: TreeContext, + totalChildren: number, + index: number, +): TreeContext { + const baseIdWithLeadingBit = baseContext.id; + const baseOverflow = baseContext.overflow; + + // The leftmost 1 marks the end of the sequence, non-inclusive. It's not part + // of the id; we use it to account for leading 0s. + const baseLength = getBitLength(baseIdWithLeadingBit) - 1; + const baseId = baseIdWithLeadingBit & ~(1 << baseLength); + + const slot = index + 1; + const length = getBitLength(totalChildren) + baseLength; + + // 30 is the max length we can store without overflowing, taking into + // consideration the leading 1 we use to mark the end of the sequence. + if (length > 30) { + // We overflowed the bitwise-safe range. Fall back to slower algorithm. + // This branch assumes the length of the base id is greater than 5; it won't + // work for smaller ids, because you need 5 bits per character. + // + // We encode the id in multiple steps: first the base id, then the + // remaining digits. + // + // Each 5 bit sequence corresponds to a single base 32 character. So for + // example, if the current id is 23 bits long, we can convert 20 of those + // bits into a string of 4 characters, with 3 bits left over. + // + // First calculate how many bits in the base id represent a complete + // sequence of characters. + const numberOfOverflowBits = baseLength - (baseLength % 5); + + // Then create a bitmask that selects only those bits. + const newOverflowBits = (1 << numberOfOverflowBits) - 1; + + // Select the bits, and convert them to a base 32 string. + const newOverflow = (baseId & newOverflowBits).toString(32); + + // Now we can remove those bits from the base id. + const restOfBaseId = baseId >> numberOfOverflowBits; + const restOfBaseLength = baseLength - numberOfOverflowBits; + + // Finally, encode the rest of the bits using the normal algorithm. Because + // we made more room, this time it won't overflow. + const restOfLength = getBitLength(totalChildren) + restOfBaseLength; + const restOfNewBits = slot << restOfBaseLength; + const id = restOfNewBits | restOfBaseId; + const overflow = newOverflow + baseOverflow; + return { + id: (1 << restOfLength) | id, + overflow, + }; + } else { + // Normal path + const newBits = slot << baseLength; + const id = newBits | baseId; + const overflow = baseOverflow; + return { + id: (1 << length) | id, + overflow, + }; + } +} + +function getBitLength(number: number): number { + return 32 - clz32(number); +} + +function getLeadingBit(id: number) { + return 1 << (getBitLength(id) - 1); +} + +// TODO: Math.clz32 is supported in Node 12+. Maybe we can drop the fallback. +const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback; + +// Count leading zeros. +// Based on: +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 +const log = Math.log; +const LN2 = Math.LN2; +function clz32Fallback(x: number): number { + const asUint = x >>> 0; + if (asUint === 0) { + return 32; + } + return (31 - ((log(asUint) / LN2) | 0)) | 0; +} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 7bd7a95c2561..bba34065cc26 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -846,6 +846,7 @@ const Dispatcher: DispatcherType = { useImperativeHandle: (unsupportedHook: any), useEffect: (unsupportedHook: any), useOpaqueIdentifier: (unsupportedHook: any), + useId: (unsupportedHook: any), useMutableSource: (unsupportedHook: any), useSyncExternalStore: (unsupportedHook: any), useCacheRefresh(): (?() => T, ?T) => void { diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index a3fe87372982..731124ea5159 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -43,6 +43,7 @@ export function waitForSuspense(fn: () => T): Promise { useDeferredValue: unsupported, useTransition: unsupported, useOpaqueIdentifier: unsupported, + useId: unsupported, useMutableSource: unsupported, useSyncExternalStore: unsupported, useCacheRefresh: unsupported, diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index c5854f8f6d39..a2e678c580b2 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -41,6 +41,7 @@ export { unstable_getCacheForType, unstable_useCacheRefresh, unstable_useOpaqueIdentifier, + unstable_useId, useCallback, useContext, useDebugValue, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 24fc9782595d..20c22828583f 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -37,6 +37,7 @@ export { unstable_getCacheForType, unstable_useCacheRefresh, unstable_useOpaqueIdentifier, + unstable_useId, useCallback, useContext, useDebugValue, diff --git a/packages/react/index.js b/packages/react/index.js index 9a6a99ee5218..3108c06c5528 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -62,6 +62,7 @@ export { unstable_getCacheForType, unstable_useCacheRefresh, unstable_useOpaqueIdentifier, + unstable_useId, useCallback, useContext, useDebugValue, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 8d08a43b9094..eef99fdabf2a 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -40,6 +40,7 @@ export { unstable_getCacheForType, unstable_useCacheRefresh, unstable_useOpaqueIdentifier, + unstable_useId, useCallback, useContext, useDebugValue, diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 867980fa5389..517dc3f8fb2d 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -30,6 +30,7 @@ export { memo, startTransition, unstable_useOpaqueIdentifier, + unstable_useId, useCallback, useContext, useDebugValue, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index d29858c9b07f..868538c83f59 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -53,6 +53,7 @@ import { useTransition, useDeferredValue, useOpaqueIdentifier, + useId, useCacheRefresh, } from './ReactHooks'; import { @@ -127,5 +128,6 @@ export { // enableScopeAPI REACT_SCOPE_TYPE as unstable_Scope, useOpaqueIdentifier as unstable_useOpaqueIdentifier, + useId as unstable_useId, act, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 1892f926c59c..1f987de1671b 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -174,6 +174,11 @@ export function useOpaqueIdentifier(): OpaqueIDType | void { return dispatcher.useOpaqueIdentifier(); } +export function useId(): string { + const dispatcher = resolveDispatcher(); + return dispatcher.useId(); +} + export function useMutableSource( source: MutableSource, getSnapshot: MutableSourceGetSnapshotFn, diff --git a/packages/react/unstable-shared-subset.experimental.js b/packages/react/unstable-shared-subset.experimental.js index a663ca8a5a89..d556400750b4 100644 --- a/packages/react/unstable-shared-subset.experimental.js +++ b/packages/react/unstable-shared-subset.experimental.js @@ -28,6 +28,7 @@ export { unstable_getCacheSignal, unstable_getCacheForType, unstable_useOpaqueIdentifier, + unstable_useId, useCallback, useContext, useDebugValue,