diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js new file mode 100644 index 000000000000..06fa2de29886 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -0,0 +1,117 @@ +/** + * 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 + */ + +'use strict'; + +let React; +let ReactDOM; +let ReactDOMServer; +let Scheduler; +let ReactFeatureFlags; +let Suspense; + +function dispatchClickEvent(target) { + const mouseOutEvent = document.createEvent('MouseEvents'); + mouseOutEvent.initMouseEvent( + 'click', + true, + true, + window, + 0, + 50, + 50, + 50, + 50, + false, + false, + false, + false, + 0, + target, + ); + return target.dispatchEvent(mouseOutEvent); +} + +describe('ReactDOMServerSelectiveHydration', () => { + beforeEach(() => { + jest.resetModuleRegistry(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableSuspenseServerRenderer = true; + ReactFeatureFlags.enableSelectiveHydration = true; + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); + Suspense = React.Suspense; + }); + + it('hydrates the target boundary synchronously during a click', async () => { + function Child({text}) { + Scheduler.unstable_yieldValue(text); + return ( + { + e.preventDefault(); + Scheduler.unstable_yieldValue('Clicked ' + text); + }}> + {text} + + ); + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B']); + + let container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + let span = container.getElementsByTagName('span')[1]; + + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + + // This should synchronously hydrate the root App and the second suspense + // boundary. + let result = dispatchClickEvent(span); + + // The event should have been canceled because we called preventDefault. + expect(result).toBe(false); + + // We rendered App, B and then invoked the event without rendering A. + expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']); + + // After continuing the scheduler, we finally hydrate A. + expect(Scheduler).toFlushAndYield(['A']); + + document.body.removeChild(container); + }); +}); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 938cf98ca6b0..7a7b4de04a5c 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -39,6 +39,7 @@ import { findHostInstanceWithWarning, flushPassiveEffects, IsThisRendererActing, + attemptSynchronousHydration, } from 'react-reconciler/inline.dom'; import {createPortal as createPortalImpl} from 'shared/ReactPortal'; import {canUseDOM} from 'shared/ExecutionEnvironment'; @@ -74,6 +75,7 @@ import { } from './ReactDOMComponentTree'; import {restoreControlledState} from './ReactDOMComponent'; import {dispatchEvent} from '../events/ReactDOMEventListener'; +import {setAttemptSynchronousHydration} from '../events/ReactDOMEventReplaying'; import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying'; import { ELEMENT_NODE, @@ -83,6 +85,8 @@ import { } from '../shared/HTMLNodeType'; import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; +setAttemptSynchronousHydration(attemptSynchronousHydration); + const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; let topLevelUpdateWarnings; diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index 3fd1655d7b3f..c0af2a5e2d1b 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -5,7 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import {HostComponent, HostText} from 'shared/ReactWorkTags'; +import { + HostComponent, + HostText, + HostRoot, + SuspenseComponent, +} from 'shared/ReactWorkTags'; import invariant from 'shared/invariant'; import {getParentSuspenseInstance} from './ReactDOMHostConfig'; @@ -112,9 +117,14 @@ export function getClosestInstanceFromNode(targetNode) { * instance, or null if the node was not rendered by this React. */ export function getInstanceFromNode(node) { - const inst = node[internalInstanceKey]; + const inst = node[internalInstanceKey] || node[internalContainerInstanceKey]; if (inst) { - if (inst.tag === HostComponent || inst.tag === HostText) { + if ( + inst.tag === HostComponent || + inst.tag === HostText || + inst.tag === SuspenseComponent || + inst.tag === HostRoot + ) { return inst; } else { return null; diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 682b9ac19b1a..76ce3c715823 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -12,7 +12,10 @@ import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; -import {enableFlareAPI} from 'shared/ReactFeatureFlags'; +import { + enableFlareAPI, + enableSelectiveHydration, +} from 'shared/ReactFeatureFlags'; import { unstable_scheduleCallback as scheduleCallback, unstable_NormalPriority as NormalPriority, @@ -25,8 +28,15 @@ import { getListeningSetForElement, listenToTopLevel, } from './ReactBrowserEventEmitter'; +import {getInstanceFromNode} from '../client/ReactDOMComponentTree'; import {unsafeCastDOMTopLevelTypeToString} from 'legacy-events/TopLevelEventTypes'; +let attemptSynchronousHydration: (fiber: Object) => void; + +export function setAttemptSynchronousHydration(fn: (fiber: Object) => void) { + attemptSynchronousHydration = fn; +} + // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. type PointerEvent = Event & { @@ -223,18 +233,36 @@ export function queueDiscreteEvent( eventSystemFlags: EventSystemFlags, nativeEvent: AnyNativeEvent, ): void { - queuedDiscreteEvents.push( - createQueuedReplayableEvent( - blockedOn, - topLevelType, - eventSystemFlags, - nativeEvent, - ), + const queuedEvent = createQueuedReplayableEvent( + blockedOn, + topLevelType, + eventSystemFlags, + nativeEvent, ); - if (blockedOn === null && queuedDiscreteEvents.length === 1) { - // This probably shouldn't happen but some defensive coding might - // help us get unblocked if we have a bug. - replayUnblockedEvents(); + queuedDiscreteEvents.push(queuedEvent); + if (enableSelectiveHydration) { + if (queuedDiscreteEvents.length === 1) { + // If this was the first discrete event, we might be able to + // synchronously unblock it so that preventDefault still works. + while (queuedEvent.blockedOn !== null) { + let fiber = getInstanceFromNode(queuedEvent.blockedOn); + if (fiber === null) { + break; + } + attemptSynchronousHydration(fiber); + if (queuedEvent.blockedOn === null) { + // We got unblocked by hydration. Let's try again. + replayUnblockedEvents(); + // If we're reblocked, on an inner boundary, we might need + // to attempt hydrating that one. + continue; + } else { + // We're still blocked from hydation, we have to give up + // and replay later. + break; + } + } + } } } diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 03378ee54a23..4aa0219ca9bc 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -27,7 +27,12 @@ import { findCurrentHostFiberWithNoPortals, } from 'react-reconciler/reflection'; import {get as getInstance} from 'shared/ReactInstanceMap'; -import {HostComponent, ClassComponent} from 'shared/ReactWorkTags'; +import { + HostComponent, + ClassComponent, + HostRoot, + SuspenseComponent, +} from 'shared/ReactWorkTags'; import getComponentName from 'shared/getComponentName'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; @@ -362,6 +367,21 @@ export function getPublicRootInstance( } } +export function attemptSynchronousHydration(fiber: Fiber): void { + switch (fiber.tag) { + case HostRoot: + let root: FiberRoot = fiber.stateNode; + if (root.hydrate) { + // Flush the first scheduled "update". + flushRoot(root, root.firstPendingTime); + } + break; + case SuspenseComponent: + flushSync(() => scheduleWork(fiber, Sync)); + break; + } +} + export {findHostInstance}; export {findHostInstanceWithWarning}; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index c7789e65fa0e..cbcd246f0eef 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -33,6 +33,7 @@ export const enableSchedulerTracing = __PROFILE__; // Only used in www builds. export const enableSuspenseServerRenderer = false; // TODO: __DEV__? Here it might just be false. +export const enableSelectiveHydration = false; // Only used in www builds. export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 12e700b0dc46..bd94a207f704 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -22,6 +22,7 @@ export const enableUserTimingAPI = __DEV__; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; export const enableSuspenseServerRenderer = false; +export const enableSelectiveHydration = false; export const enableStableConcurrentModeAPIs = false; export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index cd7a5d91b13d..bd5888cb8ba2 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -20,6 +20,7 @@ export const warnAboutDeprecatedLifecycles = true; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; export const enableSuspenseServerRenderer = false; +export const enableSelectiveHydration = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableStableConcurrentModeAPIs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index 0fb0154932e2..9605cccf29b9 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; export const enableSuspenseServerRenderer = false; +export const enableSelectiveHydration = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableStableConcurrentModeAPIs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index c30a8a2081be..bffe1419d978 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; export const enableSuspenseServerRenderer = false; +export const enableSelectiveHydration = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableStableConcurrentModeAPIs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 61144269cbca..d376426f2b57 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; export const enableSuspenseServerRenderer = false; +export const enableSelectiveHydration = false; export const enableStableConcurrentModeAPIs = false; export const enableSchedulerDebugging = false; export const warnAboutDeprecatedSetNativeProps = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index aea7c61cd823..744e2f4514db 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -16,6 +16,7 @@ export const { debugRenderPhaseSideEffectsForStrictMode, disableInputAttributeSyncing, enableTrustedTypesIntegration, + enableSelectiveHydration, } = require('ReactFeatureFlags'); // In www, we have experimental support for gathering data