From e9b525f77a69431ed93e255d8df93ea5a9f1c578 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 27 Sep 2018 15:25:38 -0700 Subject: [PATCH] pure (#13748) * pure A higher-order component version of the `React.PureComponent` class. During an update, the previous props are compared to the new props. If they are the same, React will skip rendering the component and its children. Unlike userspace implementations, `pure` will not add an additional fiber to the tree. The first argument must be a functional component; it does not work with classes. `pure` uses shallow comparison by default, like `React.PureComponent`. A custom comparison can be passed as the second argument. Co-authored-by: Andrew Clark Co-authored-by: Sophie Alpert * Warn if first argument is not a functional component --- packages/react-reconciler/src/ReactFiber.js | 29 +-- .../src/ReactFiberBeginWork.js | 218 +++++++++++++----- .../src/ReactFiberCompleteWork.js | 5 + .../src/__tests__/ReactPure-test.internal.js | 194 ++++++++++++++++ .../src/ReactTestRenderer.js | 6 + packages/react/src/React.js | 2 + packages/react/src/pure.js | 40 ++++ packages/shared/ReactSymbols.js | 1 + packages/shared/ReactWorkTags.js | 6 +- packages/shared/isValidElementType.js | 2 + 10 files changed, 433 insertions(+), 70 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactPure-test.internal.js create mode 100644 packages/react/src/pure.js diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 019436c42fc4c..6038faad3ad0c 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -37,6 +37,8 @@ import { FunctionalComponentLazy, ClassComponentLazy, ForwardRefLazy, + PureComponent, + PureComponentLazy, } from 'shared/ReactWorkTags'; import getComponentName from 'shared/getComponentName'; @@ -57,6 +59,7 @@ import { REACT_CONTEXT_TYPE, REACT_CONCURRENT_MODE_TYPE, REACT_PLACEHOLDER_TYPE, + REACT_PURE_TYPE, } from 'shared/ReactSymbols'; let hasBadMapPolyfill; @@ -300,12 +303,14 @@ export function resolveLazyComponentTag( return shouldConstruct(Component) ? ClassComponentLazy : FunctionalComponentLazy; - } else if ( - Component !== undefined && - Component !== null && - Component.$$typeof - ) { - return ForwardRefLazy; + } else if (Component !== undefined && Component !== null) { + const $$typeof = Component.$$typeof; + if ($$typeof === REACT_FORWARD_REF_TYPE) { + return ForwardRefLazy; + } + if ($$typeof === REACT_PURE_TYPE) { + return PureComponentLazy; + } } return IndeterminateComponent; } @@ -363,15 +368,8 @@ export function createWorkInProgress( } } - // Don't touching the subtree's expiration time, which has not changed. workInProgress.childExpirationTime = current.childExpirationTime; - if (pendingProps !== current.pendingProps) { - // This fiber has new props. - workInProgress.expirationTime = expirationTime; - } else { - // This fiber's props have not changed. - workInProgress.expirationTime = current.expirationTime; - } + workInProgress.expirationTime = current.expirationTime; workInProgress.child = current.child; workInProgress.memoizedProps = current.memoizedProps; @@ -460,6 +458,9 @@ export function createFiberFromElement( case REACT_FORWARD_REF_TYPE: fiberTag = ForwardRef; break getTag; + case REACT_PURE_TYPE: + fiberTag = PureComponent; + break getTag; default: { if (typeof type.then === 'function') { fiberTag = IndeterminateComponent; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 48f680cad3a3e..d66132574649c 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -31,6 +31,8 @@ import { ContextConsumer, Profiler, PlaceholderComponent, + PureComponent, + PureComponentLazy, } from 'shared/ReactWorkTags'; import { NoEffect, @@ -52,6 +54,7 @@ import { enableSchedulerTracing, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; +import shallowEqual from 'shared/shallowEqual'; import getComponentName from 'shared/getComponentName'; import ReactStrictModeWarnings from './ReactStrictModeWarnings'; import warning from 'shared/warning'; @@ -196,6 +199,58 @@ function updateForwardRef( return workInProgress.child; } +function updatePureComponent( + current: Fiber | null, + workInProgress: Fiber, + Component: any, + nextProps: any, + updateExpirationTime, + renderExpirationTime: ExpirationTime, +) { + const render = Component.render; + + if ( + current !== null && + (updateExpirationTime === NoWork || + updateExpirationTime > renderExpirationTime) + ) { + const prevProps = current.memoizedProps; + // Default to shallow comparison + let compare = Component.compare; + compare = compare !== null ? compare : shallowEqual; + if (compare(prevProps, nextProps)) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); + } + } + + // The rest is a fork of updateFunctionalComponent + let nextChildren; + prepareToReadContext(workInProgress, renderExpirationTime); + if (__DEV__) { + ReactCurrentOwner.current = workInProgress; + ReactCurrentFiber.setCurrentPhase('render'); + nextChildren = render(nextProps); + ReactCurrentFiber.setCurrentPhase(null); + } else { + nextChildren = render(nextProps); + } + + // React DevTools reads this flag. + workInProgress.effectTag |= PerformedWork; + reconcileChildren( + current, + workInProgress, + nextChildren, + renderExpirationTime, + ); + memoizeProps(workInProgress, nextProps); + return workInProgress.child; +} + function updateFragment( current: Fiber | null, workInProgress: Fiber, @@ -617,6 +672,7 @@ function mountIndeterminateComponent( current, workInProgress, Component, + updateExpirationTime, renderExpirationTime, ) { invariant( @@ -637,37 +693,53 @@ function mountIndeterminateComponent( Component, )); const resolvedProps = resolveDefaultProps(Component, props); + let child; switch (resolvedTag) { case FunctionalComponentLazy: { - return updateFunctionalComponent( + child = updateFunctionalComponent( current, workInProgress, Component, resolvedProps, renderExpirationTime, ); + break; } case ClassComponentLazy: { - return updateClassComponent( + child = updateClassComponent( current, workInProgress, Component, resolvedProps, renderExpirationTime, ); + break; } case ForwardRefLazy: { - return updateForwardRef( + child = updateForwardRef( current, workInProgress, Component, resolvedProps, renderExpirationTime, ); + break; + } + case PureComponentLazy: { + child = updatePureComponent( + current, + workInProgress, + Component, + resolvedProps, + updateExpirationTime, + renderExpirationTime, + ); + break; } default: { - // This message intentionally doesn't metion ForwardRef because the - // fact that it's a separate type of work is an implementation detail. + // This message intentionally doesn't metion ForwardRef or PureComponent + // because the fact that it's a separate type of work is an + // implementation detail. invariant( false, 'Element type is invalid. Received a promise that resolves to: %s. ' + @@ -676,6 +748,8 @@ function mountIndeterminateComponent( ); } } + workInProgress.memoizedProps = props; + return child; } const unmaskedContext = getUnmaskedContext(workInProgress, Component, false); @@ -1106,59 +1180,65 @@ function beginWork( renderExpirationTime: ExpirationTime, ): Fiber | null { const updateExpirationTime = workInProgress.expirationTime; - if ( - !hasLegacyContextChanged() && - (updateExpirationTime === NoWork || - updateExpirationTime > renderExpirationTime) - ) { - // This fiber does not have any pending work. Bailout without entering - // the begin phase. There's still some bookkeeping we that needs to be done - // in this optimized path, mostly pushing stuff onto the stack. - switch (workInProgress.tag) { - case HostRoot: - pushHostRootContext(workInProgress); - resetHydrationState(); - break; - case HostComponent: - pushHostContext(workInProgress); - break; - case ClassComponent: { - const Component = workInProgress.type; - if (isLegacyContextProvider(Component)) { - pushLegacyContextProvider(workInProgress); + + if (current !== null) { + const oldProps = current.memoizedProps; + const newProps = workInProgress.pendingProps; + if ( + oldProps === newProps && + !hasLegacyContextChanged() && + (updateExpirationTime === NoWork || + updateExpirationTime > renderExpirationTime) + ) { + // This fiber does not have any pending work. Bailout without entering + // the begin phase. There's still some bookkeeping we that needs to be done + // in this optimized path, mostly pushing stuff onto the stack. + switch (workInProgress.tag) { + case HostRoot: + pushHostRootContext(workInProgress); + resetHydrationState(); + break; + case HostComponent: + pushHostContext(workInProgress); + break; + case ClassComponent: { + const Component = workInProgress.type; + if (isLegacyContextProvider(Component)) { + pushLegacyContextProvider(workInProgress); + } + break; } - break; - } - case ClassComponentLazy: { - const thenable = workInProgress.type; - const Component = getResultFromResolvedThenable(thenable); - if (isLegacyContextProvider(Component)) { - pushLegacyContextProvider(workInProgress); + case ClassComponentLazy: { + const thenable = workInProgress.type; + const Component = getResultFromResolvedThenable(thenable); + if (isLegacyContextProvider(Component)) { + pushLegacyContextProvider(workInProgress); + } + break; } - break; - } - case HostPortal: - pushHostContainer( - workInProgress, - workInProgress.stateNode.containerInfo, - ); - break; - case ContextProvider: { - const newValue = workInProgress.memoizedProps.value; - pushProvider(workInProgress, newValue); - break; - } - case Profiler: - if (enableProfilerTimer) { - workInProgress.effectTag |= Update; + case HostPortal: + pushHostContainer( + workInProgress, + workInProgress.stateNode.containerInfo, + ); + break; + case ContextProvider: { + const newValue = workInProgress.memoizedProps.value; + pushProvider(workInProgress, newValue); + break; } - break; + case Profiler: + if (enableProfilerTimer) { + workInProgress.effectTag |= Update; + } + break; + } + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); } - return bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderExpirationTime, - ); } // Before entering the begin phase, clear the expiration time. @@ -1171,6 +1251,7 @@ function beginWork( current, workInProgress, Component, + updateExpirationTime, renderExpirationTime, ); } @@ -1252,7 +1333,7 @@ function beginWork( renderExpirationTime, ); } - case ForwardRefLazy: + case ForwardRefLazy: { const thenable = workInProgress.type; const Component = getResultFromResolvedThenable(thenable); const unresolvedProps = workInProgress.pendingProps; @@ -1265,6 +1346,7 @@ function beginWork( ); workInProgress.memoizedProps = unresolvedProps; return child; + } case Fragment: return updateFragment(current, workInProgress, renderExpirationTime); case Mode: @@ -1283,6 +1365,32 @@ function beginWork( workInProgress, renderExpirationTime, ); + case PureComponent: { + const type = workInProgress.type; + return updatePureComponent( + current, + workInProgress, + type, + workInProgress.pendingProps, + updateExpirationTime, + renderExpirationTime, + ); + } + case PureComponentLazy: { + const thenable = workInProgress.type; + const Component = getResultFromResolvedThenable(thenable); + const unresolvedProps = workInProgress.pendingProps; + const child = updatePureComponent( + current, + workInProgress, + Component, + resolveDefaultProps(Component, unresolvedProps), + updateExpirationTime, + renderExpirationTime, + ); + workInProgress.memoizedProps = unresolvedProps; + return child; + } default: invariant( false, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index cdcf541db39fb..4be43bf09656e 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -36,6 +36,8 @@ import { Profiler, PlaceholderComponent, ForwardRefLazy, + PureComponent, + PureComponentLazy, } from 'shared/ReactWorkTags'; import {Placement, Ref, Update} from 'shared/ReactSideEffectTags'; import invariant from 'shared/invariant'; @@ -524,6 +526,9 @@ function completeWork( break; case ContextConsumer: break; + case PureComponent: + case PureComponentLazy: + break; // Error cases case IndeterminateComponent: invariant( diff --git a/packages/react-reconciler/src/__tests__/ReactPure-test.internal.js b/packages/react-reconciler/src/__tests__/ReactPure-test.internal.js new file mode 100644 index 0000000000000..2813e33f9d95c --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactPure-test.internal.js @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * 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 + * @jest-environment node + */ + +/* eslint-disable no-func-assign */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactNoop; + +describe('pure', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableSuspense = true; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + }); + + function span(prop) { + return {type: 'span', children: [], prop}; + } + + function Text(props) { + ReactNoop.yield(props.text); + return ; + } + + // Tests should run against both the lazy and non-lazy versions of `pure`. + // To make the tests work for both versions, we wrap the non-lazy verion in + // a lazy function component. + sharedTests('normal', (...args) => { + const Pure = React.pure(...args); + function Indirection(props) { + return ; + } + return Promise.resolve(Indirection); + }); + sharedTests('lazy', (...args) => Promise.resolve(React.pure(...args))); + + function sharedTests(label, pure) { + describe(`${label}`, () => { + it('bails out on props equality', async () => { + const {Placeholder} = React; + + function Counter({count}) { + return ; + } + Counter = pure(Counter); + + ReactNoop.render( + + + , + ); + expect(ReactNoop.flush()).toEqual([]); + await Promise.resolve(); + expect(ReactNoop.flush()).toEqual([0]); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + // Should bail out because props have not changed + ReactNoop.render( + + + , + ); + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + // Should update because count prop changed + ReactNoop.render( + + + , + ); + expect(ReactNoop.flush()).toEqual([1]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + }); + }); + + it("does not bail out if there's a context change", async () => { + const {Placeholder} = React; + + const CountContext = React.createContext(0); + + function Counter(props) { + const count = CountContext.unstable_read(); + return ; + } + Counter = pure(Counter); + + class Parent extends React.Component { + state = {count: 0}; + render() { + return ( + + + + + + ); + } + } + + const parent = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + await Promise.resolve(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Should bail out because props have not changed + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Should update because there was a context change + parent.current.setState({count: 1}); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + it('accepts custom comparison function', async () => { + const {Placeholder} = React; + + function Counter({count}) { + return ; + } + Counter = pure(Counter, (oldProps, newProps) => { + ReactNoop.yield( + `Old count: ${oldProps.count}, New count: ${newProps.count}`, + ); + return oldProps.count === newProps.count; + }); + + ReactNoop.render( + + + , + ); + expect(ReactNoop.flush()).toEqual([]); + await Promise.resolve(); + expect(ReactNoop.flush()).toEqual([0]); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + // Should bail out because props have not changed + ReactNoop.render( + + + , + ); + expect(ReactNoop.flush()).toEqual(['Old count: 0, New count: 0']); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + // Should update because count prop changed + ReactNoop.render( + + + , + ); + expect(ReactNoop.flush()).toEqual(['Old count: 0, New count: 1', 1]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + }); + + it('warns for class components', () => { + class SomeClass extends React.Component { + render() { + return null; + } + } + expect(() => pure(SomeClass)).toWarnDev( + 'pure: The first argument must be a functional component.', + {withoutStack: true}, + ); + }); + + it('warns if first argument is not a function', () => { + expect(() => pure()).toWarnDev( + 'pure: The first argument must be a functional component. Instead ' + + 'received: undefined', + {withoutStack: true}, + ); + }); + } +}); diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index 10b664651a017..69782f10c51ed 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -30,6 +30,8 @@ import { ForwardRef, Profiler, ForwardRefLazy, + PureComponent, + PureComponentLazy, } from 'shared/ReactWorkTags'; import invariant from 'shared/invariant'; import ReactVersion from 'shared/ReactVersion'; @@ -199,6 +201,8 @@ function toTree(node: ?Fiber) { case Profiler: case ForwardRef: case ForwardRefLazy: + case PureComponent: + case PureComponentLazy: return childrenToTree(node.child); default: invariant( @@ -217,6 +221,8 @@ const validWrapperTypes = new Set([ HostComponent, ForwardRef, ForwardRefLazy, + PureComponent, + PureComponentLazy, // Normally skipped, but used when there's more than one root child. HostRoot, ]); diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 27a5638a0845a..c85cb29cc8170 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -27,6 +27,7 @@ import { import {createContext} from './ReactContext'; import {lazy} from './ReactLazy'; import forwardRef from './forwardRef'; +import pure from './pure'; import { createElementWithValidation, createFactoryWithValidation, @@ -49,6 +50,7 @@ const React = { createContext, forwardRef, + pure, Fragment: REACT_FRAGMENT_TYPE, StrictMode: REACT_STRICT_MODE_TYPE, diff --git a/packages/react/src/pure.js b/packages/react/src/pure.js new file mode 100644 index 0000000000000..1d2aa6952c2e6 --- /dev/null +++ b/packages/react/src/pure.js @@ -0,0 +1,40 @@ +/** + * 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. + */ + +import {REACT_PURE_TYPE} from 'shared/ReactSymbols'; + +import warningWithoutStack from 'shared/warningWithoutStack'; + +export default function pure( + render: (props: Props) => React$Node, + compare?: (oldProps: Props, newProps: Props) => boolean, +) { + if (__DEV__) { + if (typeof render !== 'function') { + warningWithoutStack( + false, + 'pure: The first argument must be a functional component. Instead ' + + 'received: %s', + render === null ? 'null' : typeof render, + ); + } else { + const prototype = render.prototype; + if (prototype && prototype.isReactComponent) { + warningWithoutStack( + false, + 'pure: The first argument must be a functional component. Classes ' + + 'are not supported. Use React.PureComponent instead.', + ); + } + } + } + return { + $$typeof: REACT_PURE_TYPE, + render, + compare: compare === undefined ? null : compare, + }; +} diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index a13a5fc792eb8..68f7e10a52de5 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -41,6 +41,7 @@ export const REACT_FORWARD_REF_TYPE = hasSymbol export const REACT_PLACEHOLDER_TYPE = hasSymbol ? Symbol.for('react.placeholder') : 0xead1; +export const REACT_PURE_TYPE = hasSymbol ? Symbol.for('react.pure') : 0xead3; const MAYBE_ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; const FAUX_ITERATOR_SYMBOL = '@@iterator'; diff --git a/packages/shared/ReactWorkTags.js b/packages/shared/ReactWorkTags.js index 1a639d78338ec..78c22567c95ff 100644 --- a/packages/shared/ReactWorkTags.js +++ b/packages/shared/ReactWorkTags.js @@ -24,7 +24,9 @@ export type WorkTag = | 13 | 14 | 15 - | 16; + | 16 + | 17 + | 18; export const FunctionalComponent = 0; export const FunctionalComponentLazy = 1; @@ -43,3 +45,5 @@ export const ForwardRef = 13; export const ForwardRefLazy = 14; export const Profiler = 15; export const PlaceholderComponent = 16; +export const PureComponent = 17; +export const PureComponentLazy = 18; diff --git a/packages/shared/isValidElementType.js b/packages/shared/isValidElementType.js index 22fab9d39aae3..6997c275f41f0 100644 --- a/packages/shared/isValidElementType.js +++ b/packages/shared/isValidElementType.js @@ -16,6 +16,7 @@ import { REACT_PROVIDER_TYPE, REACT_STRICT_MODE_TYPE, REACT_PLACEHOLDER_TYPE, + REACT_PURE_TYPE, } from 'shared/ReactSymbols'; export default function isValidElementType(type: mixed) { @@ -31,6 +32,7 @@ export default function isValidElementType(type: mixed) { (typeof type === 'object' && type !== null && (typeof type.then === 'function' || + type.$$typeof === REACT_PURE_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE))