From e50388e3eb3b8326d436fb55e0c0ea06db7811a9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 27 Aug 2019 21:34:14 -0700 Subject: [PATCH] Attach the Root fiber to the DOM container This lets us detect if an event happens on this root's subtree before it has rendered something. --- ...DOMServerPartialHydration-test.internal.js | 82 +++++++++++++++++++ .../ReactServerRenderingHydration-test.js | 58 +++++++++++++ packages/react-dom/src/client/ReactDOM.js | 3 + .../src/events/EnterLeaveEventPlugin.js | 4 + .../src/events/ReactDOMEventListener.js | 30 ++++++- 5 files changed, 174 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 7f76fa403a79a..f4e465ea4d39a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -1975,4 +1975,86 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + + it('does not invoke an event on a parent tree when a subtree is dehydrated', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + let clicks = 0; + let childSlotRef = React.createRef(); + + function Parent() { + return
clicks++} ref={childSlotRef} />; + } + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return Click me; + } + } + + function App() { + // The root is a Suspense boundary. + return ( + + + + ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + let parentContainer = document.createElement('div'); + let childContainer = document.createElement('div'); + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(parentContainer); + + // We're going to use a different root as a parent. + // This lets us detect whether an event goes through React's event system. + let parentRoot = ReactDOM.unstable_createRoot(parentContainer); + parentRoot.render(); + Scheduler.unstable_flushAll(); + + childSlotRef.current.appendChild(childContainer); + + childContainer.innerHTML = finalHTML; + + let a = childContainer.getElementsByTagName('a')[0]; + + suspend = true; + + // Hydrate asynchronously. + let root = ReactDOM.unstable_createRoot(childContainer, {hydrate: true}); + root.render(); + jest.runAllTimers(); + Scheduler.unstable_flushAll(); + + // The Suspense boundary is not yet hydrated. + a.click(); + expect(clicks).toBe(0); + + // Resolving the promise so that rendering can complete. + suspend = false; + resolve(); + await promise; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // We're now full hydrated. + // TODO: With selective hydration the event should've been replayed + // but for now we'll have to issue it again. + act(() => { + a.click(); + }); + + expect(clicks).toBe(1); + + document.body.removeChild(parentContainer); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index a533923d5b731..819855bbfcee9 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -586,4 +586,62 @@ describe('ReactDOMServerHydration', () => { document.body.removeChild(container); }); + + it('does not invoke an event on a parent tree when a subtree is hydrating', () => { + let clicks = 0; + let childSlotRef = React.createRef(); + + function Parent() { + return
clicks++} ref={childSlotRef} />; + } + + function App() { + return ( +
+ Click me +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + + let parentContainer = document.createElement('div'); + let childContainer = document.createElement('div'); + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(parentContainer); + + // We're going to use a different root as a parent. + // This lets us detect whether an event goes through React's event system. + let parentRoot = ReactDOM.unstable_createRoot(parentContainer); + parentRoot.render(); + Scheduler.unstable_flushAll(); + + childSlotRef.current.appendChild(childContainer); + + childContainer.innerHTML = finalHTML; + + let a = childContainer.getElementsByTagName('a')[0]; + + // Hydrate asynchronously. + let root = ReactDOM.unstable_createRoot(childContainer, {hydrate: true}); + root.render(); + // Nothing has rendered so far. + + a.click(); + expect(clicks).toBe(0); + + Scheduler.unstable_flushAll(); + + // We're now full hydrated. + // TODO: With selective hydration the event should've been replayed + // but for now we'll have to issue it again. + act(() => { + a.click(); + }); + + expect(clicks).toBe(1); + + document.body.removeChild(parentContainer); + }); }); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index e11756cb6bf88..ecef6d7bd216b 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -70,6 +70,7 @@ import { getNodeFromInstance, getFiberCurrentPropsFromNode, getClosestInstanceFromNode, + precacheFiberNode, } from './ReactDOMComponentTree'; import {restoreControlledState} from './ReactDOMComponent'; import {dispatchEvent} from '../events/ReactDOMEventListener'; @@ -375,6 +376,7 @@ function ReactSyncRoot( (options != null && options.hydrationOptions) || null; const root = createContainer(container, tag, hydrate, hydrationCallbacks); this._internalRoot = root; + precacheFiberNode(root.current, container); } function ReactRoot(container: DOMContainer, options: void | RootOptions) { @@ -388,6 +390,7 @@ function ReactRoot(container: DOMContainer, options: void | RootOptions) { hydrationCallbacks, ); this._internalRoot = root; + precacheFiberNode(root.current, container); } ReactRoot.prototype.render = ReactSyncRoot.prototype.render = function( diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js index c16db1c111163..4133006488171 100644 --- a/packages/react-dom/src/events/EnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/EnterLeaveEventPlugin.js @@ -19,6 +19,7 @@ import { getClosestInstanceFromNode, getNodeFromInstance, } from '../client/ReactDOMComponentTree'; +import {HostComponent, HostText} from 'shared/ReactWorkTags'; const eventTypes = { mouseEnter: { @@ -89,6 +90,9 @@ const EnterLeaveEventPlugin = { from = targetInst; const related = nativeEvent.relatedTarget || nativeEvent.toElement; to = related ? getClosestInstanceFromNode(related) : null; + if (to !== null && to.tag !== HostComponent && to.tag !== HostText) { + to = null; + } } else { // Moving to a node from outside the window. from = null; diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 2afb35aa0c5af..e02296ba1dffc 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -23,7 +23,12 @@ import { import {runExtractedPluginEventsInBatch} from 'legacy-events/EventPluginHub'; import {dispatchEventForResponderEventSystem} from '../events/DOMEventResponderSystem'; import {isFiberMounted} from 'react-reconciler/reflection'; -import {HostRoot, SuspenseComponent} from 'shared/ReactWorkTags'; +import { + HostRoot, + SuspenseComponent, + HostComponent, + HostText, +} from 'shared/ReactWorkTags'; import { type EventSystemFlags, PLUGIN_EVENT_SYSTEM, @@ -77,6 +82,9 @@ type BookKeepingInstance = { * other). If React trees are not nested, returns null. */ function findRootContainerNode(inst) { + if (inst.tag === HostRoot) { + return inst.stateNode.containerInfo; + } // TODO: It may be a good idea to cache this to prevent unnecessary DOM // traversal, but caching is difficult to do correctly without using a // mutation observer to listen for all DOM changes. @@ -141,8 +149,15 @@ function handleTopLevel(bookKeeping: BookKeepingInstance) { if (!root) { break; } - bookKeeping.ancestors.push(ancestor); - ancestor = getClosestInstanceFromNode(root); + if (ancestor.tag === HostComponent || ancestor.tag === HostText) { + bookKeeping.ancestors.push(ancestor); + } + let parent = root.parentNode; + if (parent) { + ancestor = getClosestInstanceFromNode(parent); + } else { + ancestor = null; + } } while (ancestor); for (let i = 0; i < bookKeeping.ancestors.length; i++) { @@ -319,6 +334,15 @@ export function dispatchEvent( // For now we're going to just ignore this event as if it's // not mounted. targetInst = null; + } else if (targetInst.tag === HostRoot) { + if (targetInst.alternate === null) { + // We have not yet mounted/hydrated the first children. + // TODO: This is a good opportunity to schedule a replay of + // the event instead once this root has been hydrated. + // For now we're going to just ignore this event as if it's + // not mounted. + targetInst = null; + } } } else { // If we get an event (ex: img onload) before committing that