From 33b50f5af948f0111e19eecee9f4f9a3dc3c20a7 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 26 Oct 2021 16:03:28 -0400 Subject: [PATCH 1/5] Add useId to dispatcher --- .../react-debug-tools/src/ReactDebugHooks.js | 5 ++ .../src/server/ReactPartialRendererHooks.js | 5 ++ .../src/ReactFiberHooks.new.js | 50 +++++++++++++++++++ .../src/ReactFiberHooks.old.js | 50 +++++++++++++++++++ .../src/ReactInternalTypes.js | 2 + packages/react-server/src/ReactFizzHooks.js | 5 ++ .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactSuspenseTestUtils.js | 1 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/index.stable.js | 1 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 5 ++ .../unstable-shared-subset.experimental.js | 1 + 16 files changed, 132 insertions(+) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 957838ed58a9c..b9268219c2c4d 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -341,6 +341,10 @@ function useOpaqueIdentifier(): OpaqueIDType | void { return value; } +function useId(): string { + throw new Error('Not implemented.'); +} + const Dispatcher: DispatcherType = { getCacheForType, readContext, @@ -361,6 +365,7 @@ const Dispatcher: DispatcherType = { useSyncExternalStore, useDeferredValue, useOpaqueIdentifier, + useId, }; // Inspect diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 168fd78f6103e..26f2dd00ee0c6 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/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 732f7d71a7e71..4600a9380e95e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -2109,6 +2109,14 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void { return id; } +function mountId(): string { + throw new Error('Not implemented.'); +} + +function updateId(): string { + throw new Error('Not implemented.'); +} + function mountRefresh() { const hook = mountWorkInProgressHook(); const refresh = (hook.memoizedState = refreshCache.bind( @@ -2425,6 +2433,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, + useId: throwInvalidHookError, unstable_isNewReconciler: enableNewReconciler, }; @@ -2453,6 +2462,7 @@ const HooksDispatcherOnMount: Dispatcher = { useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, + useId: mountId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2481,6 +2491,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2509,6 +2520,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2680,6 +2692,11 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2822,6 +2839,11 @@ if (__DEV__) { updateHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2964,6 +2986,11 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3107,6 +3134,11 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3266,6 +3298,12 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3425,6 +3463,12 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3585,6 +3629,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 b78f24e8b47f8..10723722d72a9 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -2109,6 +2109,14 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void { return id; } +function mountId(): string { + throw new Error('Not implemented.'); +} + +function updateId(): string { + throw new Error('Not implemented.'); +} + function mountRefresh() { const hook = mountWorkInProgressHook(); const refresh = (hook.memoizedState = refreshCache.bind( @@ -2425,6 +2433,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, + useId: throwInvalidHookError, unstable_isNewReconciler: enableNewReconciler, }; @@ -2453,6 +2462,7 @@ const HooksDispatcherOnMount: Dispatcher = { useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, + useId: mountId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2481,6 +2491,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2509,6 +2520,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, + useId: updateId, unstable_isNewReconciler: enableNewReconciler, }; @@ -2680,6 +2692,11 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2822,6 +2839,11 @@ if (__DEV__) { updateHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -2964,6 +2986,11 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3107,6 +3134,11 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3266,6 +3298,12 @@ if (__DEV__) { mountHookTypesDev(); return mountOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3425,6 +3463,12 @@ if (__DEV__) { updateHookTypesDev(); return updateOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; @@ -3585,6 +3629,12 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOpaqueIdentifier(); }, + useId(): string { + currentHookNameInDev = 'useId'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateId(); + }, unstable_isNewReconciler: enableNewReconciler, }; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index e971828233c10..9dac4f40aad53 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-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 2596632652b43..44fa71b556f31 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -495,6 +495,10 @@ function useOpaqueIdentifier(): OpaqueIDType { return makeServerID(currentResponseState); } +function useId(): string { + throw new Error('Not implemented.'); +} + function unsupportedRefresh() { throw new Error('Cache cannot be refreshed during server rendering.'); } @@ -524,6 +528,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/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 7bd7a95c25611..bba34065cc268 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 a3fe873729828..731124ea51593 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 c5854f8f6d398..a2e678c580b2e 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 24fc9782595d4..20c22828583f5 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 9a6a99ee52189..3108c06c55284 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 8d08a43b90946..eef99fdabf2a4 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 867980fa5389d..517dc3f8fb2db 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 d29858c9b07fd..868538c83f59b 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 1892f926c59cf..1f987de1671ba 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 a663ca8a5a89d..d556400750b4c 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, From 45174c080e14a163e7fa3d3e47052c9eece3ec96 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 28 Oct 2021 10:04:40 -0400 Subject: [PATCH 2/5] Initial useId implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .../src/__tests__/ReactDOMUseId-test.js | 311 ++++++++++++++++++ .../src/ReactChildFiber.new.js | 32 +- .../src/ReactChildFiber.old.js | 32 +- .../src/ReactFiberBeginWork.new.js | 22 ++ .../src/ReactFiberBeginWork.old.js | 22 ++ .../src/ReactFiberCompleteWork.new.js | 7 +- .../src/ReactFiberCompleteWork.old.js | 7 +- .../react-reconciler/src/ReactFiberFlags.js | 55 ++-- .../src/ReactFiberHooks.new.js | 53 ++- .../src/ReactFiberHooks.old.js | 53 ++- .../src/ReactFiberLane.new.js | 15 +- .../src/ReactFiberLane.old.js | 15 +- .../src/ReactFiberTreeContext.new.js | 241 ++++++++++++++ .../src/ReactFiberTreeContext.old.js | 241 ++++++++++++++ .../src/ReactFiberUnwindWork.new.js | 11 + .../src/ReactFiberUnwindWork.old.js | 11 + packages/react-reconciler/src/clz32.js | 25 ++ packages/react-server/src/ReactFizzHooks.js | 36 +- packages/react-server/src/ReactFizzServer.js | 83 ++++- .../react-server/src/ReactFizzTreeContext.js | 168 ++++++++++ 20 files changed, 1359 insertions(+), 81 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMUseId-test.js create mode 100644 packages/react-reconciler/src/ReactFiberTreeContext.new.js create mode 100644 packages/react-reconciler/src/ReactFiberTreeContext.old.js create mode 100644 packages/react-reconciler/src/clz32.js create mode 100644 packages/react-server/src/ReactFizzTreeContext.js 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 0000000000000..2b989d415af14 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -0,0 +1,311 @@ +/** + * 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 clientAct; +let ReactDOMFizzServer; +let Stream; +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'); + clientAct = require('jest-react').act; + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + 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 + +
+ `); + }); +}); diff --git a/packages/react-reconciler/src/ReactChildFiber.new.js b/packages/react-reconciler/src/ReactChildFiber.new.js index 9071edc24f7a2..658b1f0e7b799 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 0128ca8f36f3d..0ef3b301e95a7 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 4fe648bc3e767..aac6239d8bbd5 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); @@ -3675,6 +3682,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 f116897a8661b..8d34832a3df48 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); @@ -3675,6 +3682,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 20a7fc52db13a..feb38f00461a0 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 305359aef206e..ea4d71e8ba371 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 a82278222bf0a..805c4bed918e9 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 4600a9380e95e..a1d7009a85a43 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 { @@ -2110,11 +2134,36 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void { } function mountId(): string { - throw new Error('Not implemented.'); + 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 { - throw new Error('Not implemented.'); + const hook = updateWorkInProgressHook(); + const id: string = hook.memoizedState; + return id; } function mountRefresh() { diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 10723722d72a9..167698271dbac 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 { @@ -2110,11 +2134,36 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void { } function mountId(): string { - throw new Error('Not implemented.'); + 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 { - throw new Error('Not implemented.'); + const hook = updateWorkInProgressHook(); + const id: string = hook.memoizedState; + return id; } function mountRefresh() { diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index c1f34e1052fc4..7e1461c7a7226 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 c81191f6a07e5..6b4be15e649f1 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/ReactFiberTreeContext.new.js b/packages/react-reconciler/src/ReactFiberTreeContext.new.js new file mode 100644 index 0000000000000..2227727be3fbb --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberTreeContext.new.js @@ -0,0 +1,241 @@ +/** + * 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'; + +// 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; + } +} + +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 0000000000000..2ef2caa0fc514 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberTreeContext.old.js @@ -0,0 +1,241 @@ +/** + * 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'; + +// 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; + } +} + +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 ba8bd4b573957..bb002b9e71b3a 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 ad8e479700db0..7f161513a4afa 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/clz32.js b/packages/react-reconciler/src/clz32.js new file mode 100644 index 0000000000000..80a9cfb911482 --- /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 44fa71b556f31..bf1a2d56e0f93 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; @@ -496,7 +514,21 @@ function useOpaqueIdentifier(): OpaqueIDType { } function useId(): string { - throw new Error('Not implemented.'); + 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() { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 6692b92648643..f06cbc8fb61a3 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 0000000000000..c9a47e5af72a6 --- /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; +} From 20675c5e5ac14b3373bcf60d59174ee02e542a13 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 28 Oct 2021 22:30:22 -0400 Subject: [PATCH 3/5] Incremental hydration Stores the tree context on the dehydrated Suspense boundary's state object so it resume where it left off. --- .../src/__tests__/ReactDOMUseId-test.js | 146 ++++++++++++++++++ .../src/ReactFiberBeginWork.new.js | 2 + .../src/ReactFiberBeginWork.old.js | 2 + .../src/ReactFiberHydrationContext.new.js | 10 ++ .../src/ReactFiberHydrationContext.old.js | 10 ++ .../src/ReactFiberSuspenseComponent.new.js | 3 + .../src/ReactFiberSuspenseComponent.old.js | 3 + .../src/ReactFiberTreeContext.new.js | 32 ++++ .../src/ReactFiberTreeContext.old.js | 32 ++++ 9 files changed, 240 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index 2b989d415af14..f26f5609e3a48 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -10,9 +10,11 @@ let JSDOM; let React; let ReactDOM; +let Scheduler; let clientAct; let ReactDOMFizzServer; let Stream; +let Suspense; let useId; let document; let writable; @@ -27,9 +29,11 @@ describe('useId', () => { 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 @@ -86,6 +90,11 @@ describe('useId', () => { } } + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + function normalizeTreeIdForTesting(id) { const [serverClientPrefix, base32, hookIndex] = id.split(':'); if (serverClientPrefix === 'r') { @@ -308,4 +317,141 @@ describe('useId', () => {
`); }); + + 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 a sibling before a dehydrated Suspense boundary', async () => { + const span = React.createRef(null); + function App({showMore}) { + // Note: Using a dynamic array so this is treated as an insertion instead + // of an update, because Fiber currently allocates a node even for + // empty children. + const children = []; + if (showMore) { + // These are client-only nodes. They aren't not included in the initial + // server render. + children.push(, ); + } + children.push( + + + + + , + , + ); + + return children; + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['A']); + const dehydratedSpan = container.getElementsByTagName('span')[0]; + await clientAct(async () => { + const root = ReactDOM.hydrateRoot(container, ); + expect(Scheduler).toFlushUntilNextPaint(['A']); + expect(container).toMatchInlineSnapshot(` +
+ A + + +
+
+ + +
+
+ `); + + // The inner boundary hasn't hydrated yet + expect(span.current).toBe(null); + + // Insert another sibling before the Suspense boundary + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'A', + 'B', + // The update triggers selective hydration so we render again + 'A', + 'B', + ]); + // The insertions should not cause a mismatch. + expect(container).toMatchInlineSnapshot(` +
+ A + + + B +
+
+
+ + +
+
+ `); + // Should have hydrated successfully + expect(span.current).toBe(dehydratedSpan); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index aac6239d8bbd5..653ee9e1b4ea7 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 8d34832a3df48..9833ef481af70 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 7275f1663cad8..eabc5e43116bb 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 654de3f9a2894..48e60581e0f28 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/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index 5ad7ae650249a..9dbaf7fb76efd 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 51bef1df3a568..726f0ca52005f 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 index 2227727be3fbb..0725ba577e647 100644 --- a/packages/react-reconciler/src/ReactFiberTreeContext.new.js +++ b/packages/react-reconciler/src/ReactFiberTreeContext.new.js @@ -64,6 +64,11 @@ 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 @@ -229,6 +234,33 @@ export function popTreeContext(workInProgress: Fiber) { } } +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()) { diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.old.js b/packages/react-reconciler/src/ReactFiberTreeContext.old.js index 2ef2caa0fc514..a4ba3c3ddb931 100644 --- a/packages/react-reconciler/src/ReactFiberTreeContext.old.js +++ b/packages/react-reconciler/src/ReactFiberTreeContext.old.js @@ -64,6 +64,11 @@ 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 @@ -229,6 +234,33 @@ export function popTreeContext(workInProgress: Fiber) { } } +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()) { From 2a4e9c953d5984c0f7b36220c32a44c8f25c3b74 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 30 Oct 2021 18:01:11 -0400 Subject: [PATCH 4/5] Add useId to react-debug-tools --- .../react-debug-tools/src/ReactDebugHooks.js | 9 +++++- .../ReactHooksInspectionIntegration-test.js | 29 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index b9268219c2c4d..eed8c46df7d6d 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -342,7 +342,14 @@ function useOpaqueIdentifier(): OpaqueIDType | void { } function useId(): string { - throw new Error('Not implemented.'); + const hook = nextHook(); + const id = hook !== null ? hook.memoizedState : ''; + hookLog.push({ + primitive: 'Id', + stackError: new Error(), + value: id, + }); + return id; } const Dispatcher: DispatcherType = { diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 3a6ac01f98161..d17a01a258277 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) { From 371302f0144079a7b9f04c3c485ee19cf82b936f Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 1 Nov 2021 16:26:56 -0400 Subject: [PATCH 5/5] Add selective hydration test Demonstrates that selective hydration works and ids are preserved even after subsequent client updates. --- .../src/__tests__/ReactDOMUseId-test.js | 160 ++++++++++++------ 1 file changed, 109 insertions(+), 51 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index f26f5609e3a48..b61e79fa670d6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -90,11 +90,6 @@ describe('useId', () => { } } - function Text({text}) { - Scheduler.unstable_yieldValue(text); - return text; - } - function normalizeTreeIdForTesting(id) { const [serverClientPrefix, base32, hookIndex] = id.split(':'); if (serverClientPrefix === 'r') { @@ -359,96 +354,159 @@ describe('useId', () => { `); }); - test('inserting a sibling before a dehydrated Suspense boundary', async () => { + test('inserting/deleting siblings outside a dehydrated Suspense boundary', async () => { const span = React.createRef(null); - function App({showMore}) { - // Note: Using a dynamic array so this is treated as an insertion instead - // of an update, because Fiber currently allocates a node even for - // empty children. - const children = []; - if (showMore) { - // These are client-only nodes. They aren't not included in the initial - // server render. - children.push(, ); - } - children.push( + 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} - , - , + ); - - return children; } await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); pipe(writable); }); - expect(Scheduler).toHaveYielded(['A']); const dehydratedSpan = container.getElementsByTagName('span')[0]; await clientAct(async () => { const root = ReactDOM.hydrateRoot(container, ); - expect(Scheduler).toFlushUntilNextPaint(['A']); + expect(Scheduler).toFlushUntilNextPaint([]); expect(container).toMatchInlineSnapshot(`
- A -
- -
+ +
`); // The inner boundary hasn't hydrated yet expect(span.current).toBe(null); - // Insert another sibling before the Suspense boundary - root.render(); + // Swap B for C + root.render(); }); - expect(Scheduler).toHaveYielded([ - 'A', - 'B', - // The update triggers selective hydration so we render again - 'A', - 'B', - ]); - // The insertions should not cause a mismatch. + // The swap should not have caused a mismatch. expect(container).toMatchInlineSnapshot(`
- A - - B
-
`); // Should have hydrated successfully