diff --git a/packages/eslint-plugin-react-hooks/README.md b/packages/eslint-plugin-react-hooks/README.md new file mode 100644 index 0000000000000..156f09718b35d --- /dev/null +++ b/packages/eslint-plugin-react-hooks/README.md @@ -0,0 +1,48 @@ +# `eslint-plugin-react-hooks` + +This ESLint plugin enforces the [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html). + +It is a part of the [Hooks proposal](https://reactjs.org/docs/hooks-intro.html) for React. + +## Experimental Status + +This is an experimental release and is intended to be used for testing the Hooks proposal with React 16.7 alpha. The exact heuristics it uses may be adjusted. + +The [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html) documentation contains a link to the technical RFC. Please leave a comment on the RFC if you have concerns or ideas about how this plugin should work. + +## Installation + +**Note: If you're using Create React App, please wait for a corresponding experimental release of `react-scripts` that includes this rule instead of adding it directly.** + +Assuming you already have ESLint installed, run: + +```sh +# npm +npm install eslint-plugin-react-hooks@next --save-dev + +# yarn +yarn add eslint-plugin-react-hooks@next --dev +``` + +Then add it to your ESLint configuration: + +```js +{ + "plugins": [ + // ... + "react-hooks" + ], + "rules": { + // ... + "react-hooks/rules-of-hooks": "error" + } +} +``` + +## Valid and Invalid Examples + +Please refer to the [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html) documentation and the [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#what-exactly-do-the-lint-rules-enforce) to learn more about this rule. + +## License + +MIT diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js new file mode 100644 index 0000000000000..b8807db3c56f4 --- /dev/null +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -0,0 +1,632 @@ +/** + * 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. + */ + +'use strict'; + +const ESLintTester = require('eslint').RuleTester; +const ReactHooksESLintPlugin = require('eslint-plugin-react-hooks'); +const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['rules-of-hooks']; + +ESLintTester.setDefaultConfig({ + parser: 'babel-eslint', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +const eslintTester = new ESLintTester(); +eslintTester.run('react-hooks', ReactHooksESLintRule, { + valid: [ + ` + // Valid because components can use hooks. + function ComponentWithHook() { + useHook(); + } + `, + ` + // Valid because components can use hooks. + function createComponentWithHook() { + return function ComponentWithHook() { + useHook(); + }; + } + `, + ` + // Valid because hooks can use hooks. + function useHookWithHook() { + useHook(); + } + `, + ` + // Valid because hooks can use hooks. + function createHook() { + return function useHookWithHook() { + useHook(); + } + } + `, + ` + // Valid because components can call functions. + function ComponentWithNormalFunction() { + doSomething(); + } + `, + ` + // Valid because functions can call functions. + function normalFunctionWithNormalFunction() { + doSomething(); + } + `, + ` + // Valid because functions can call functions. + function normalFunctionWithConditionalFunction() { + if (cond) { + doSomething(); + } + } + `, + ` + // Valid because functions can call functions. + function functionThatStartsWithUseButIsntAHook() { + if (cond) { + userFetch(); + } + } + `, + ` + // Valid although unconditional return doesn't make sense and would fail other rules. + // We could make it invalid but it doesn't matter. + function useUnreachable() { + return; + useHook(); + } + `, + ` + // Valid because hooks can call hooks. + function useHook() { useState(); } + const whatever = function useHook() { useState(); }; + const useHook1 = () => { useState(); }; + let useHook2 = () => useState(); + useHook2 = () => { useState(); }; + ({useHook: () => { useState(); }}); + ({useHook() { useState(); }}); + const {useHook = () => { useState(); }} = {}; + ({useHook = () => { useState(); }} = {}); + `, + ` + // Valid because hooks can call hooks. + function useHook() { + useHook1(); + useHook2(); + } + `, + ` + // Valid because hooks can call hooks. + function createHook() { + return function useHook() { + useHook1(); + useHook2(); + }; + } + `, + ` + // Valid because hooks can call hooks. + function useHook() { + useState() && a; + } + `, + ` + // Valid because hooks can call hooks. + function useHook() { + return useHook1() + useHook2(); + } + `, + ` + // Valid because hooks can call hooks. + function useHook() { + return useHook1(useHook2()); + } + `, + ` + // Valid because classes can call functions. + // We don't consider these to be hooks. + class C { + m() { + this.useHook(); + super.useHook(); + } + } + `, + ` + // Currently valid. + // We *could* make this invalid if we want, but it creates false positives + // (see the FooStore case). + class C { + m() { + This.useHook(); + Super.useHook(); + } + } + `, + ` + // Valid although we *could* consider these invalid. + // But it doesn't bring much benefit since it's an immediate runtime error anyway. + // So might as well allow it. + Hook.use(); + Hook._use(); + Hook.useState(); + Hook._useState(); + Hook.use42(); + Hook.useHook(); + Hook.use_hook(); + `, + ` + // Valid -- this is a regression test. + jest.useFakeTimers(); + beforeEach(() => { + jest.useRealTimers(); + }) + `, + ` + // Valid because that's a false positive we've seen quite a bit. + // This is a regression test. + class Foo extends Component { + render() { + if (cond) { + FooStore.useFeatureFlag(); + } + } + } + `, + ` + // Currently valid because we found this to be a common pattern + // for feature flag checks in existing components. + // We *could* make it invalid but that produces quite a few false positives. + // Why does it make sense to ignore it? Firstly, because using + // hooks in a class would cause a runtime error anyway. + // But why don't we care about the same kind of false positive in a functional + // component? Because even if it was a false positive, it would be confusing + // anyway. So it might make sense to rename a feature flag check in that case. + class ClassComponentWithFeatureFlag extends React.Component { + render() { + if (foo) { + useFeatureFlag(); + } + } + } + `, + ` + // Currently valid because we don't check for hooks in classes. + // See ClassComponentWithFeatureFlag for rationale. + // We *could* make it invalid if we don't regress that false positive. + class ClassComponentWithHook extends React.Component { + render() { + React.useState(); + } + } + `, + ` + // Currently valid. + // These are variations capturing the current heuristic-- + // we only allow hooks in PascalCase, useFoo functions, + // or classes (due to common false positives and because they error anyway). + // We *could* make some of these invalid. + // They probably don't matter much. + (class {useHook = () => { useState(); }}); + (class {useHook() { useState(); }}); + (class {h = () => { useState(); }}); + (class {i() { useState(); }}); + `, + ` + // Currently valid although we *could* consider these invalid. + // It doesn't make a lot of difference because it would crash early. + use(); + _use(); + useState(); + _useState(); + use42(); + useHook(); + use_hook(); + React.useState(); + `, + ` + // Regression test for the popular "history" library + const {createHistory, useBasename} = require('history-2.1.2'); + const browserHistory = useBasename(createHistory)({ + basename: '/', + }); + `, + ` + // Regression test for some internal code. + // This shows how the "callback rule" is more relaxed, + // and doesn't kick in unless we're confident we're in + // a component or a hook. + function makeListener(instance) { + each(pixelsWithInferredEvents, pixel => { + if (useExtendedSelector(pixel.id) && extendedButton) { + foo(); + } + }); + } + `, + ` + // Regression test for incorrectly flagged valid code. + function RegressionTest() { + const foo = cond ? a : b; + useState(); + } + `, + ], + invalid: [ + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function ComponentWithConditionalHook() { + if (cond) { + useConditionalHook(); + } + } + `, + errors: [conditionalError('useConditionalHook')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function createComponent() { + return function ComponentWithConditionalHook() { + if (cond) { + useConditionalHook(); + } + } + } + `, + errors: [conditionalError('useConditionalHook')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHookWithConditionalHook() { + if (cond) { + useConditionalHook(); + } + } + `, + errors: [conditionalError('useConditionalHook')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function createHook() { + return function useHookWithConditionalHook() { + if (cond) { + useConditionalHook(); + } + } + } + `, + errors: [conditionalError('useConditionalHook')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function ComponentWithTernaryHook() { + cond ? useTernaryHook() : null; + } + `, + errors: [conditionalError('useTernaryHook')], + }, + { + code: ` + // Invalid because it's a common misunderstanding. + // We *could* make it valid but the runtime error could be confusing. + function ComponentWithHookInsideCallback() { + useEffect(() => { + useHookInsideCallback(); + }); + } + `, + errors: [genericError('useHookInsideCallback')], + }, + { + code: ` + // Invalid because it's a common misunderstanding. + // We *could* make it valid but the runtime error could be confusing. + function createComponent() { + return function ComponentWithHookInsideCallback() { + useEffect(() => { + useHookInsideCallback(); + }); + } + } + `, + errors: [genericError('useHookInsideCallback')], + }, + { + code: ` + // Invalid because it's a common misunderstanding. + // We *could* make it valid but the runtime error could be confusing. + function ComponentWithHookInsideCallback() { + function handleClick() { + useState(); + } + } + `, + errors: [functionError('useState', 'handleClick')], + }, + { + code: ` + // Invalid because it's a common misunderstanding. + // We *could* make it valid but the runtime error could be confusing. + function createComponent() { + return function ComponentWithHookInsideCallback() { + function handleClick() { + useState(); + } + } + } + `, + errors: [functionError('useState', 'handleClick')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function ComponentWithHookInsideLoop() { + while (cond) { + useHookInsideLoop(); + } + } + `, + errors: [loopError('useHookInsideLoop')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function renderItem() { + useState(); + } + + function List(props) { + return props.items.map(renderItem); + } + `, + errors: [functionError('useState', 'renderItem')], + }, + { + code: ` + // Currently invalid because it violates the convention and removes the "taint" + // from a hook. We *could* make it valid to avoid some false positives but let's + // ensure that we don't break the "renderItem" and "normalFunctionWithConditionalHook" + // cases which must remain invalid. + function normalFunctionWithHook() { + useHookInsideNormalFunction(); + } + `, + errors: [ + functionError('useHookInsideNormalFunction', 'normalFunctionWithHook'), + ], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function normalFunctionWithConditionalHook() { + if (cond) { + useHookInsideNormalFunction(); + } + } + `, + errors: [ + functionError( + 'useHookInsideNormalFunction', + 'normalFunctionWithConditionalHook' + ), + ], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHookInLoops() { + while (a) { + useHook1(); + if (b) return; + useHook2(); + } + while (c) { + useHook3(); + if (d) return; + useHook4(); + } + } + `, + errors: [ + loopError('useHook1'), + loopError('useHook2'), + loopError('useHook3'), + loopError('useHook4'), + ], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHookInLoops() { + while (a) { + useHook1(); + if (b) continue; + useHook2(); + } + } + `, + errors: [ + loopError('useHook1'), + + // NOTE: Small imprecision in error reporting due to caching means we + // have a conditional error here instead of a loop error. However, + // we will always get an error so this is acceptable. + conditionalError('useHook2', true), + ], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useLabeledBlock() { + label: { + if (a) break label; + useHook(); + } + } + `, + errors: [conditionalError('useHook')], + }, + { + code: ` + // Currently invalid. + // These are variations capturing the current heuristic-- + // we only allow hooks in PascalCase or useFoo functions. + // We *could* make some of these valid. But before doing it, + // consider specific cases documented above that contain reasoning. + function a() { useState(); } + const whatever = function b() { useState(); }; + const c = () => { useState(); }; + let d = () => useState(); + e = () => { useState(); }; + ({f: () => { useState(); }}); + ({g() { useState(); }}); + const {j = () => { useState(); }} = {}; + ({k = () => { useState(); }} = {}); + `, + errors: [ + functionError('useState', 'a'), + functionError('useState', 'b'), + functionError('useState', 'c'), + functionError('useState', 'd'), + functionError('useState', 'e'), + functionError('useState', 'f'), + functionError('useState', 'g'), + functionError('useState', 'j'), + functionError('useState', 'k'), + ], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHook() { + if (a) return; + useState(); + } + `, + errors: [conditionalError('useState', true)], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHook() { + if (a) return; + if (b) { + console.log('true'); + } else { + console.log('false'); + } + useState(); + } + `, + errors: [conditionalError('useState', true)], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHook() { + if (b) { + console.log('true'); + } else { + console.log('false'); + } + if (a) return; + useState(); + } + `, + errors: [conditionalError('useState', true)], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHook() { + a && useHook1(); + b && useHook2(); + } + `, + errors: [conditionalError('useHook1'), conditionalError('useHook2')], + }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHook() { + try { + f(); + useState(); + } catch {} + } + `, + errors: [ + // NOTE: This is an error since `f()` could possibly throw. + conditionalError('useState'), + ], + }, + ], +}); + +function conditionalError(hook, hasPreviousFinalizer = false) { + return { + message: + `React Hook "${hook}" is called conditionally. React Hooks must be ` + + 'called in the exact same order in every component render.' + + (hasPreviousFinalizer + ? ' Did you accidentally call a React Hook after an early return?' + : ''), + }; +} + +function loopError(hook) { + return { + message: + `React Hook "${hook}" may be executed more than once. Possibly ` + + 'because it is called in a loop. React Hooks must be called in the ' + + 'exact same order in every component render.', + }; +} + +function functionError(hook, fn) { + return { + message: + `React Hook "${hook}" is called in function "${fn}" which is neither ` + + 'a React function component or a custom React Hook function.', + }; +} + +function genericError(hook) { + return { + message: + `React Hook "${hook}" cannot be called inside a callback. React Hooks ` + + 'must be called in a React function component or a custom React ' + + 'Hook function.', + }; +} diff --git a/packages/eslint-plugin-react-hooks/index.js b/packages/eslint-plugin-react-hooks/index.js new file mode 100644 index 0000000000000..7ab3284345f0b --- /dev/null +++ b/packages/eslint-plugin-react-hooks/index.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +'use strict'; + +module.exports = require('./src/index'); diff --git a/packages/eslint-plugin-react-hooks/npm/index.js b/packages/eslint-plugin-react-hooks/npm/index.js new file mode 100644 index 0000000000000..0e91baf6cd189 --- /dev/null +++ b/packages/eslint-plugin-react-hooks/npm/index.js @@ -0,0 +1,9 @@ +'use strict'; + +// TODO: this doesn't make sense for an ESLint rule. +// We need to fix our build process to not create bundles for "raw" packages like this. +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/eslint-plugin-react-hooks.production.min.js'); +} else { + module.exports = require('./cjs/eslint-plugin-react-hooks.development.js'); +} diff --git a/packages/eslint-plugin-react-hooks/package.json b/packages/eslint-plugin-react-hooks/package.json new file mode 100644 index 0000000000000..f779252a27653 --- /dev/null +++ b/packages/eslint-plugin-react-hooks/package.json @@ -0,0 +1,29 @@ +{ + "name": "eslint-plugin-react-hooks", + "description": "ESLint rules for React Hooks", + "version": "0.0.0", + "repository": "facebook/react", + "files": [ + "LICENSE", + "README.md", + "index.js", + "cjs" + ], + "keywords": [ + "eslint", + "eslint-plugin", + "eslintplugin", + "react" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/facebook/react/issues" + }, + "engines": { + "node": ">=6" + }, + "homepage": "https://reactjs.org/", + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0" + } +} diff --git a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js new file mode 100644 index 0000000000000..e77afbf3c6822 --- /dev/null +++ b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js @@ -0,0 +1,545 @@ +/** + * 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. + */ + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +'use strict'; + +/** + * Catch all identifiers that begin with "use" followed by an uppercase Latin + * character to exclude identifiers like "user". + */ + +function isHookName(s) { + return /^use[A-Z0-9].*$/.test(s); +} + +/** + * We consider hooks to be a hook name identifier or a member expression + * containing a hook name. + */ + +function isHook(node) { + if (node.type === 'Identifier') { + return isHookName(node.name); + } else if ( + node.type === 'MemberExpression' && + !node.computed && + isHook(node.property) + ) { + // Only consider React.useFoo() to be namespace hooks for now to avoid false positives. + // We can expand this check later. + const obj = node.object; + return obj.type === 'Identifier' && obj.name === 'React'; + } else { + return false; + } +} + +/** + * Checks if the node is a React component name. React component names must + * always start with a non-lowercase letter. So `MyComponent` or `_MyComponent` + * are valid component names for instance. + */ + +function isComponentName(node) { + if (node.type === 'Identifier') { + return !/^[a-z]/.test(node.name); + } else { + return false; + } +} + +function isInsideComponentOrHook(node) { + while (node) { + const functionName = getFunctionName(node); + if (functionName) { + if (isComponentName(functionName) || isHook(functionName)) { + return true; + } + } + node = node.parent; + } + return false; +} + +export default { + create(context) { + const codePathReactHooksMapStack = []; + const codePathSegmentStack = []; + return { + // Maintain code segment path stack as we traverse. + onCodePathSegmentStart: segment => codePathSegmentStack.push(segment), + onCodePathSegmentEnd: () => codePathSegmentStack.pop(), + + // Maintain code path stack as we traverse. + onCodePathStart: () => codePathReactHooksMapStack.push(new Map()), + + // Process our code path. + // + // Everything is ok if all React Hooks are both reachable from the initial + // segment and reachable from every final segment. + onCodePathEnd(codePath, codePathNode) { + const reactHooksMap = codePathReactHooksMapStack.pop(); + if (reactHooksMap.size === 0) { + return; + } + + // All of the segments which are cyclic are recorded in this set. + const cyclic = new Set(); + + /** + * Count the number of code paths from the start of the function to this + * segment. For example: + * + * ```js + * function MyComponent() { + * if (condition) { + * // Segment 1 + * } else { + * // Segment 2 + * } + * // Segment 3 + * } + * ``` + * + * Segments 1 and 2 have one path to the beginning of `MyComponent` and + * segment 3 has two paths to the beginning of `MyComponent` since we + * could have either taken the path of segment 1 or segment 2. + * + * Populates `cyclic` with cyclic segments. + */ + + function countPathsFromStart(segment) { + const {cache} = countPathsFromStart; + let paths = cache.get(segment.id); + + // If `paths` is null then we've found a cycle! Add it to `cyclic` and + // any other segments which are a part of this cycle. + if (paths === null) { + if (cyclic.has(segment.id)) { + return 0; + } else { + cyclic.add(segment.id); + for (const prevSegment of segment.prevSegments) { + countPathsFromStart(prevSegment); + } + return 0; + } + } + + // We have a cached `paths`. Return it. + if (paths !== undefined) { + return paths; + } + + // Compute `paths` and cache it. Guarding against cycles. + cache.set(segment.id, null); + if (segment.prevSegments.length === 0) { + paths = 1; + } else { + paths = 0; + for (const prevSegment of segment.prevSegments) { + paths += countPathsFromStart(prevSegment); + } + } + cache.set(segment.id, paths); + + return paths; + } + + /** + * Count the number of code paths from this segment to the end of the + * function. For example: + * + * ```js + * function MyComponent() { + * // Segment 1 + * if (condition) { + * // Segment 2 + * } else { + * // Segment 3 + * } + * } + * ``` + * + * Segments 2 and 3 have one path to the end of `MyComponent` and + * segment 1 has two paths to the end of `MyComponent` since we could + * either take the path of segment 1 or segment 2. + * + * Populates `cyclic` with cyclic segments. + */ + + function countPathsToEnd(segment) { + const {cache} = countPathsToEnd; + let paths = cache.get(segment.id); + + // If `paths` is null then we've found a cycle! Add it to `cyclic` and + // any other segments which are a part of this cycle. + if (paths === null) { + if (cyclic.has(segment.id)) { + return 0; + } else { + cyclic.add(segment.id); + for (const nextSegment of segment.nextSegments) { + countPathsToEnd(nextSegment); + } + return 0; + } + } + + // We have a cached `paths`. Return it. + if (paths !== undefined) { + return paths; + } + + // Compute `paths` and cache it. Guarding against cycles. + cache.set(segment.id, null); + if (segment.nextSegments.length === 0) { + paths = 1; + } else { + paths = 0; + for (const nextSegment of segment.nextSegments) { + paths += countPathsToEnd(nextSegment); + } + } + cache.set(segment.id, paths); + + return paths; + } + + /** + * Gets the shortest path length to the start of a code path. + * For example: + * + * ```js + * function MyComponent() { + * if (condition) { + * // Segment 1 + * } + * // Segment 2 + * } + * ``` + * + * There is only one path from segment 1 to the code path start. Its + * length is one so that is the shortest path. + * + * There are two paths from segment 2 to the code path start. One + * through segment 1 with a length of two and another directly to the + * start with a length of one. The shortest path has a length of one + * so we would return that. + */ + + function shortestPathLengthToStart(segment) { + const {cache} = shortestPathLengthToStart; + let length = cache.get(segment.id); + + // If `length` is null then we found a cycle! Return infinity since + // the shortest path is definitely not the one where we looped. + if (length === null) { + return Infinity; + } + + // We have a cached `length`. Return it. + if (length !== undefined) { + return length; + } + + // Compute `length` and cache it. Guarding against cycles. + cache.set(segment.id, null); + if (segment.prevSegments.length === 0) { + length = 1; + } else { + length = Infinity; + for (const prevSegment of segment.prevSegments) { + const prevLength = shortestPathLengthToStart(prevSegment); + if (prevLength < length) { + length = prevLength; + } + } + length += 1; + } + cache.set(segment.id, length); + return length; + } + + countPathsFromStart.cache = new Map(); + countPathsToEnd.cache = new Map(); + shortestPathLengthToStart.cache = new Map(); + + // Count all code paths to the end of our component/hook. Also primes + // the `countPathsToEnd` cache. + const allPathsFromStartToEnd = countPathsToEnd(codePath.initialSegment); + + // Gets the function name for our code path. If the function name is + // `undefined` then we know either that we have an anonymous function + // expression or our code path is not in a function. In both cases we + // will want to error since neither are React functional components or + // hook functions. + const codePathFunctionName = getFunctionName(codePathNode); + + // This is a valid code path for React hooks if we are direcly in a React + // functional component or we are in a hook function. + const isSomewhereInsideComponentOrHook = isInsideComponentOrHook( + codePathNode, + ); + const isDirectlyInsideComponentOrHook = codePathFunctionName + ? isComponentName(codePathFunctionName) || + isHook(codePathFunctionName) + : false; + + // Compute the earliest finalizer level using information from the + // cache. We expect all reachable final segments to have a cache entry + // after calling `visitSegment()`. + let shortestFinalPathLength = Infinity; + for (const finalSegment of codePath.finalSegments) { + if (!finalSegment.reachable) { + continue; + } + const length = shortestPathLengthToStart(finalSegment); + if (length < shortestFinalPathLength) { + shortestFinalPathLength = length; + } + } + + // Make sure all React Hooks pass our lint invariants. Log warnings + // if not. + for (const [segment, reactHooks] of reactHooksMap) { + // NOTE: We could report here that the hook is not reachable, but + // that would be redundant with more general "no unreachable" + // lint rules. + if (!segment.reachable) { + continue; + } + + // If there are any final segments with a shorter path to start then + // we possibly have an early return. + // + // If our segment is a final segment itself then siblings could + // possibly be early returns. + const possiblyHasEarlyReturn = + segment.nextSegments.length === 0 + ? shortestFinalPathLength <= shortestPathLengthToStart(segment) + : shortestFinalPathLength < shortestPathLengthToStart(segment); + + // Count all the paths from the start of our code path to the end of + // our code path that go _through_ this segment. The critical piece + // of this is _through_. If we just call `countPathsToEnd(segment)` + // then we neglect that we may have gone through multiple paths to get + // to this point! Consider: + // + // ```js + // function MyComponent() { + // if (a) { + // // Segment 1 + // } else { + // // Segment 2 + // } + // // Segment 3 + // if (b) { + // // Segment 4 + // } else { + // // Segment 5 + // } + // } + // ``` + // + // In this component we have four code paths: + // + // 1. `a = true; b = true` + // 2. `a = true; b = false` + // 3. `a = false; b = true` + // 4. `a = false; b = false` + // + // From segment 3 there are two code paths to the end through segment + // 4 and segment 5. However, we took two paths to get here through + // segment 1 and segment 2. + // + // If we multiply the paths from start (two) by the paths to end (two) + // for segment 3 we get four. Which is our desired count. + const pathsFromStartToEnd = + countPathsFromStart(segment) * countPathsToEnd(segment); + + // Is this hook a part of a cyclic segment? + const cycled = cyclic.has(segment.id); + + for (const hook of reactHooks) { + // Report an error if a hook may be called more then once. + if (cycled) { + context.report( + hook, + `React Hook "${context.getSource(hook)}" may be executed ` + + 'more than once. Possibly because it is called in a loop. ' + + 'React Hooks must be called in the exact same order in ' + + 'every component render.', + ); + } + + // If this is not a valid code path for React hooks then we need to + // log a warning for every hook in this code path. + // + // Pick a special message depending on the scope this hook was + // called in. + if (isDirectlyInsideComponentOrHook) { + // Report an error if a hook does not reach all finalizing code + // path segments. + // + // Special case when we think there might be an early return. + if (!cycled && pathsFromStartToEnd !== allPathsFromStartToEnd) { + context.report( + hook, + `React Hook "${context.getSource(hook)}" is called ` + + 'conditionally. React Hooks must be called in the exact ' + + 'same order in every component render.' + + (possiblyHasEarlyReturn + ? ' Did you accidentally call a React Hook after an' + + ' early return?' + : ''), + ); + } + } else if ( + codePathNode.parent && + (codePathNode.parent.type === 'MethodDefinition' || + codePathNode.parent.type === 'ClassProperty') && + codePathNode.parent.value === codePathNode + ) { + // Ignore class methods for now because they produce too many + // false positives due to feature flag checks. We're less + // sensitive to them in classes because hooks would produce + // runtime errors in classes anyway, and because a use*() + // call in a class, if it works, is unambigously *not* a hook. + } else if (codePathFunctionName) { + // Custom message if we found an invalid function name. + context.report( + hook, + `React Hook "${context.getSource(hook)}" is called in ` + + `function "${context.getSource(codePathFunctionName)}" ` + + 'which is neither a React function component or a custom ' + + 'React Hook function.', + ); + } else if (codePathNode.type === 'Program') { + // For now, ignore if it's in top level scope. + // We could warn here but there are false positives related + // configuring libraries like `history`. + } else { + // Assume in all other cases the user called a hook in some + // random function callback. This should usually be true for + // anonymous function expressions. Hopefully this is clarifying + // enough in the common case that the incorrect message in + // uncommon cases doesn't matter. + if (isSomewhereInsideComponentOrHook) { + context.report( + hook, + `React Hook "${context.getSource(hook)}" cannot be called ` + + 'inside a callback. React Hooks must be called in a ' + + 'React function component or a custom React Hook function.', + ); + } + } + } + } + }, + + // Missed opportunity...We could visit all `Identifier`s instead of all + // `CallExpression`s and check that _every use_ of a hook name is valid. + // But that gets complicated and enters type-system territory, so we're + // only being strict about hook calls for now. + CallExpression(node) { + if (isHook(node.callee)) { + // Add the hook node to a map keyed by the code path segment. We will + // do full code path analysis at the end of our code path. + const reactHooksMap = last(codePathReactHooksMapStack); + const codePathSegment = last(codePathSegmentStack); + let reactHooks = reactHooksMap.get(codePathSegment); + if (!reactHooks) { + reactHooks = []; + reactHooksMap.set(codePathSegment, reactHooks); + } + reactHooks.push(node.callee); + } + }, + }; + }, +}; + +/** + * Gets tbe static name of a function AST node. For function declarations it is + * easy. For anonymous function expressions it is much harder. If you search for + * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places + * where JS gives anonymous function expressions names. We roughly detect the + * same AST nodes with some exceptions to better fit our usecase. + */ + +function getFunctionName(node) { + if ( + node.type === 'FunctionDeclaration' || + (node.type === 'FunctionExpression' && node.id) + ) { + // function useHook() {} + // const whatever = function useHook() {}; + // + // Function declaration or function expression names win over any + // assignment statements or other renames. + return node.id; + } else if ( + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + if ( + node.parent.type === 'VariableDeclarator' && + node.parent.init === node + ) { + // const useHook = () => {}; + return node.parent.id; + } else if ( + node.parent.type === 'AssignmentExpression' && + node.parent.right === node && + node.parent.operator === '=' + ) { + // useHook = () => {}; + return node.parent.left; + } else if ( + node.parent.type === 'Property' && + node.parent.value === node && + !node.parent.computed + ) { + // {useHook: () => {}} + // {useHook() {}} + return node.parent.key; + + // NOTE: We could also support `ClassProperty` and `MethodDefinition` + // here to be pedantic. However, hooks in a class are an anti-pattern. So + // we don't allow it to error early. + // + // class {useHook = () => {}} + // class {useHook() {}} + } else if ( + node.parent.type === 'AssignmentPattern' && + node.parent.right === node && + !node.parent.computed + ) { + // const {useHook = () => {}} = {}; + // ({useHook = () => {}} = {}); + // + // Kinda clowny, but we'd said we'd follow spec convention for + // `IsAnonymousFunctionDefinition()` usage. + return node.parent.left; + } else { + return undefined; + } + } else { + return undefined; + } +} + +/** + * Convenience function for peeking the last item in a stack. + */ + +function last(array) { + return array[array.length - 1]; +} diff --git a/packages/eslint-plugin-react-hooks/src/index.js b/packages/eslint-plugin-react-hooks/src/index.js new file mode 100644 index 0000000000000..a3d6216e097bc --- /dev/null +++ b/packages/eslint-plugin-react-hooks/src/index.js @@ -0,0 +1,14 @@ +/** + * 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. + */ + +'use strict'; + +import RuleOfHooks from './RulesOfHooks'; + +export const rules = { + 'rules-of-hooks': RuleOfHooks, +}; diff --git a/packages/react-dom/src/__tests__/ReactCompositeComponent-test.js b/packages/react-dom/src/__tests__/ReactCompositeComponent-test.js index 115544170a83e..43ac793cf59cd 100644 --- a/packages/react-dom/src/__tests__/ReactCompositeComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactCompositeComponent-test.js @@ -332,7 +332,7 @@ describe('ReactCompositeComponent', () => { ReactDOM.unmountComponentAtNode(container); expect(() => instance.forceUpdate()).toWarnDev( - "Warning: Can't call setState (or forceUpdate) on an unmounted " + + "Warning: Can't perform a React state update on an unmounted " + 'component. This is a no-op, but it indicates a memory leak in your ' + 'application. To fix, cancel all subscriptions and asynchronous ' + 'tasks in the componentWillUnmount method.\n' + @@ -379,7 +379,7 @@ describe('ReactCompositeComponent', () => { expect(() => { instance.setState({value: 2}); }).toWarnDev( - "Warning: Can't call setState (or forceUpdate) on an unmounted " + + "Warning: Can't perform a React state update on an unmounted " + 'component. This is a no-op, but it indicates a memory leak in your ' + 'application. To fix, cancel all subscriptions and asynchronous ' + 'tasks in the componentWillUnmount method.\n' + diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js new file mode 100644 index 0000000000000..37315929e42fb --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js @@ -0,0 +1,666 @@ +/** + * 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 + */ + +/* eslint-disable no-func-assign */ + +'use strict'; + +const ReactDOMServerIntegrationUtils = require('./utils/ReactDOMServerIntegrationTestUtils'); + +let React; +let ReactFeatureFlags; +let ReactDOM; +let ReactDOMServer; +let useState; +let useReducer; +let useEffect; +let useContext; +let useCallback; +let useMemo; +let useRef; +let useImperativeMethods; +let useMutationEffect; +let useLayoutEffect; +let forwardRef; +let yieldedValues; +let yieldValue; +let clearYields; + +function initModules() { + // Reset warning cache. + jest.resetModuleRegistry(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableHooks = true; + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + useState = React.useState; + useReducer = React.useReducer; + useEffect = React.useEffect; + useContext = React.useContext; + useCallback = React.useCallback; + useMemo = React.useMemo; + useRef = React.useRef; + useImperativeMethods = React.useImperativeMethods; + useMutationEffect = React.useMutationEffect; + useLayoutEffect = React.useLayoutEffect; + forwardRef = React.forwardRef; + + yieldedValues = []; + yieldValue = value => { + yieldedValues.push(value); + }; + clearYields = () => { + const ret = yieldedValues; + yieldedValues = []; + return ret; + }; + + // Make them available to the helpers. + return { + ReactDOM, + ReactDOMServer, + }; +} + +const { + resetModules, + itRenders, + itThrowsWhenRendering, + serverRender, +} = ReactDOMServerIntegrationUtils(initModules); + +describe('ReactDOMServerHooks', () => { + beforeEach(() => { + resetModules(); + }); + + function Text(props) { + yieldValue(props.text); + return {props.text}; + } + + describe('useState', () => { + itRenders('basic render', async render => { + function Counter(props) { + const [count] = useState(0); + return Count: {count}; + } + + const domNode = await render(); + expect(domNode.textContent).toEqual('Count: 0'); + }); + + itRenders('lazy state initialization', async render => { + function Counter(props) { + const [count] = useState(() => { + return 0; + }); + return Count: {count}; + } + + const domNode = await render(); + expect(domNode.textContent).toEqual('Count: 0'); + }); + + it('does not trigger a re-renders when updater is invoked outside current render function', async () => { + function UpdateCount({setCount, count, children}) { + if (count < 3) { + setCount(c => c + 1); + } + return {children}; + } + function Counter() { + let [count, setCount] = useState(0); + return ( +
+ + Count: {count} + +
+ ); + } + + const domNode = await serverRender(); + expect(domNode.textContent).toEqual('Count: 0'); + }); + + itThrowsWhenRendering( + 'if used inside a class component', + async render => { + class Counter extends React.Component { + render() { + let [count] = useState(0); + return ; + } + } + + return render(); + }, + 'Hooks can only be called inside the body of a function component.', + ); + + itRenders('multiple times when an updater is called', async render => { + function Counter() { + let [count, setCount] = useState(0); + if (count < 12) { + setCount(c => c + 1); + setCount(c => c + 1); + setCount(c => c + 1); + } + return ; + } + + const domNode = await render(); + expect(domNode.textContent).toEqual('Count: 12'); + }); + + itRenders('until there are no more new updates', async render => { + function Counter() { + let [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + return Count: {count}; + } + + const domNode = await render(); + expect(domNode.textContent).toEqual('Count: 3'); + }); + + itThrowsWhenRendering( + 'after too many iterations', + async render => { + function Counter() { + let [count, setCount] = useState(0); + setCount(count + 1); + return {count}; + } + return render(); + }, + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + }); + + describe('useReducer', () => { + itRenders('with initial state', async render => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter() { + let [count] = useReducer(reducer, 0); + yieldValue('Render: ' + count); + return ; + } + + const domNode = await render(); + + expect(clearYields()).toEqual(['Render: 0', 0]); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('0'); + }); + + itRenders('lazy initialization with initialAction', async render => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter() { + let [count] = useReducer(reducer, 0, 'increment'); + yieldValue('Render: ' + count); + return ; + } + + const domNode = await render(); + + expect(clearYields()).toEqual(['Render: 1', 1]); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('1'); + }); + + itRenders( + 'multiple times when updates happen during the render phase', + async render => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter() { + let [count, dispatch] = useReducer(reducer, 0); + if (count < 3) { + dispatch('increment'); + } + yieldValue('Render: ' + count); + return ; + } + + const domNode = await render(); + + expect(clearYields()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('3'); + }, + ); + + itRenders( + 'using reducer passed at time of render, not time of dispatch', + async render => { + // This test is a bit contrived but it demonstrates a subtle edge case. + + // Reducer A increments by 1. Reducer B increments by 10. + function reducerA(state, action) { + switch (action) { + case 'increment': + return state + 1; + case 'reset': + return 0; + } + } + function reducerB(state, action) { + switch (action) { + case 'increment': + return state + 10; + case 'reset': + return 0; + } + } + + function Counter() { + let [reducer, setReducer] = useState(() => reducerA); + let [count, dispatch] = useReducer(reducer, 0); + if (count < 20) { + dispatch('increment'); + // Swap reducers each time we increment + if (reducer === reducerA) { + setReducer(() => reducerB); + } else { + setReducer(() => reducerA); + } + } + yieldValue('Render: ' + count); + return ; + } + + const domNode = await render(); + + expect(clearYields()).toEqual([ + // The count should increase by alternating amounts of 10 and 1 + // until we reach 21. + 'Render: 0', + 'Render: 10', + 'Render: 11', + 'Render: 21', + 21, + ]); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('21'); + }, + ); + }); + + describe('useMemo', () => { + itRenders('basic render', async render => { + function CapitalizedText(props) { + const text = props.text; + const capitalizedText = useMemo( + () => { + yieldValue(`Capitalize '${text}'`); + return text.toUpperCase(); + }, + [text], + ); + return ; + } + + const domNode = await render(); + expect(clearYields()).toEqual(["Capitalize 'hello'", 'HELLO']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('HELLO'); + }); + + itRenders('if no inputs are provided', async render => { + function LazyCompute(props) { + const computed = useMemo(props.compute); + return ; + } + + function computeA() { + yieldValue('compute A'); + return 'A'; + } + + const domNode = await render(); + expect(clearYields()).toEqual(['compute A', 'A']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('A'); + }); + + itRenders( + 'multiple times when updates happen during the render phase', + async render => { + function CapitalizedText(props) { + const [text, setText] = useState(props.text); + const capitalizedText = useMemo( + () => { + yieldValue(`Capitalize '${text}'`); + return text.toUpperCase(); + }, + [text], + ); + + if (text === 'hello') { + setText('hello, world.'); + } + return ; + } + + const domNode = await render(); + expect(clearYields()).toEqual([ + "Capitalize 'hello'", + "Capitalize 'hello, world.'", + 'HELLO, WORLD.', + ]); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('HELLO, WORLD.'); + }, + ); + + itRenders( + 'should only invoke the memoized function when the inputs change', + async render => { + function CapitalizedText(props) { + const [text, setText] = useState(props.text); + const [count, setCount] = useState(0); + const capitalizedText = useMemo( + () => { + yieldValue(`Capitalize '${text}'`); + return text.toUpperCase(); + }, + [text], + ); + + yieldValue(count); + + if (count < 3) { + setCount(count + 1); + } + + if (text === 'hello' && count === 2) { + setText('hello, world.'); + } + return ; + } + + const domNode = await render(); + expect(clearYields()).toEqual([ + "Capitalize 'hello'", + 0, + 1, + 2, + // `capitalizedText` only recomputes when the text has changed + "Capitalize 'hello, world.'", + 3, + 'HELLO, WORLD.', + ]); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('HELLO, WORLD.'); + }, + ); + }); + + describe('useRef', () => { + itRenders('basic render', async render => { + function Counter(props) { + const count = useRef(0); + return Count: {count.current}; + } + + const domNode = await render(); + expect(domNode.textContent).toEqual('Count: 0'); + }); + + itRenders( + 'multiple times when updates happen during the render phase', + async render => { + function Counter(props) { + const [count, setCount] = useState(0); + const ref = useRef(count); + + if (count < 3) { + const newCount = count + 1; + + ref.current = newCount; + setCount(newCount); + } + + yieldValue(count); + + return Count: {ref.current}; + } + + const domNode = await render(); + expect(clearYields()).toEqual([0, 1, 2, 3]); + expect(domNode.textContent).toEqual('Count: 3'); + }, + ); + + itRenders( + 'always return the same reference through multiple renders', + async render => { + let firstRef = null; + function Counter(props) { + const [count, setCount] = useState(0); + const ref = useRef(count); + if (firstRef === null) { + firstRef = ref; + } else if (firstRef !== ref) { + throw new Error('should never change'); + } + + if (count < 3) { + setCount(count + 1); + } else { + firstRef = null; + } + + yieldValue(count); + + return Count: {ref.current}; + } + + const domNode = await render(); + expect(clearYields()).toEqual([0, 1, 2, 3]); + expect(domNode.textContent).toEqual('Count: 0'); + }, + ); + }); + + describe('useEffect', () => { + itRenders('should ignore effects on the server', async render => { + function Counter(props) { + useEffect(() => { + yieldValue('should not be invoked'); + }); + return ; + } + const domNode = await render(); + expect(clearYields()).toEqual(['Count: 0']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('Count: 0'); + }); + }); + + describe('useCallback', () => { + itRenders('should ignore callbacks on the server', async render => { + function Counter(props) { + useCallback(() => { + yieldValue('should not be invoked'); + }); + return ; + } + const domNode = await render(); + expect(clearYields()).toEqual(['Count: 0']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('Count: 0'); + }); + }); + + describe('useImperativeMethods', () => { + it('should not be invoked on the server', async () => { + function Counter(props, ref) { + useImperativeMethods(ref, () => { + throw new Error('should not be invoked'); + }); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(); + counter.current = 0; + const domNode = await serverRender( + , + ); + expect(clearYields()).toEqual(['Count: 0']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('Count: 0'); + }); + }); + + describe('useMutationEffect', () => { + it('should warn when invoked during render', async () => { + function Counter() { + useMutationEffect(() => { + throw new Error('should not be invoked'); + }); + + return ; + } + const domNode = await serverRender(, 1); + expect(clearYields()).toEqual(['Count: 0']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('Count: 0'); + }); + }); + + describe('useLayoutEffect', () => { + it('should warn when invoked during render', async () => { + function Counter() { + useLayoutEffect(() => { + throw new Error('should not be invoked'); + }); + + return ; + } + const domNode = await serverRender(, 1); + expect(clearYields()).toEqual(['Count: 0']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('Count: 0'); + }); + }); + + describe('useContext', () => { + itRenders( + 'can use the same context multiple times in the same function', + async render => { + const Context = React.createContext( + {foo: 0, bar: 0, baz: 0}, + (a, b) => { + let result = 0; + if (a.foo !== b.foo) { + result |= 0b001; + } + if (a.bar !== b.bar) { + result |= 0b010; + } + if (a.baz !== b.baz) { + result |= 0b100; + } + return result; + }, + ); + + function Provider(props) { + return ( + + {props.children} + + ); + } + + function FooAndBar() { + const {foo} = useContext(Context, 0b001); + const {bar} = useContext(Context, 0b010); + return ; + } + + function Baz() { + const {baz} = useContext(Context, 0b100); + return ; + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + function App(props) { + return ( +
+ + + + + + + + + + +
+ ); + } + + const domNode = await render(); + expect(clearYields()).toEqual(['Foo: 1, Bar: 3', 'Baz: 5']); + expect(domNode.childNodes.length).toBe(2); + expect(domNode.firstChild.tagName).toEqual('SPAN'); + expect(domNode.firstChild.textContent).toEqual('Foo: 1, Bar: 3'); + expect(domNode.lastChild.tagName).toEqual('SPAN'); + expect(domNode.lastChild.textContent).toEqual('Baz: 5'); + }, + ); + + itThrowsWhenRendering( + 'if used inside a class component', + async render => { + const Context = React.createContext({}, () => {}); + class Counter extends React.Component { + render() { + let [count] = useContext(Context); + return ; + } + } + + return render(); + }, + 'Hooks can only be called inside the body of a function component.', + ); + }); +}); diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 6b482be43e378..1ee885c68abc2 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -48,6 +48,11 @@ import { createMarkupForRoot, } from './DOMMarkupOperations'; import escapeTextForBrowser from './escapeTextForBrowser'; +import { + prepareToUseHooks, + finishHooks, + Dispatcher, +} from './ReactPartialRendererHooks'; import { Namespaces, getIntrinsicNamespace, @@ -87,15 +92,6 @@ let pushCurrentDebugStack = (stack: Array) => {}; let pushElementToDebugStack = (element: ReactElement) => {}; let popCurrentDebugStack = () => {}; -let Dispatcher = { - readContext( - context: ReactContext, - observedBits: void | number | boolean, - ): T { - return context._currentValue; - }, -}; - if (__DEV__) { ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame; @@ -573,7 +569,11 @@ function resolve( } } } + const componentIdentity = {}; + prepareToUseHooks(componentIdentity); inst = Component(element.props, publicContext, updater); + inst = finishHooks(Component, element.props, inst, publicContext); + if (inst == null || inst.render == null) { child = inst; validateRenderResult(child, Component); @@ -985,9 +985,17 @@ class ReactDOMServerRenderer { switch (elementType.$$typeof) { case REACT_FORWARD_REF_TYPE: { const element: ReactElement = ((nextChild: any): ReactElement); - const nextChildren = toArray( - elementType.render(element.props, element.ref), + let nextChildren; + const componentIdentity = {}; + prepareToUseHooks(componentIdentity); + nextChildren = elementType.render(element.props, element.ref); + nextChildren = finishHooks( + elementType.render, + element.props, + nextChildren, + element.ref, ); + nextChildren = toArray(nextChildren); const frame: Frame = { type: null, domNamespace: parentNamespace, diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js new file mode 100644 index 0000000000000..1420f1bcc9a2a --- /dev/null +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -0,0 +1,374 @@ +/** + * 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 + */ +import type {ReactContext} from 'shared/ReactTypes'; + +import invariant from 'shared/invariant'; +import warning from 'shared/warning'; + +type BasicStateAction = (S => S) | S; +type MaybeCallback = void | null | (S => mixed); +type Dispatch = (A, MaybeCallback) => void; + +type Update = { + action: A, + next: Update | null, +}; + +type UpdateQueue = { + last: Update | null, + dispatch: any, +}; + +type Hook = { + memoizedState: any, + queue: UpdateQueue | null, + next: Hook | null, +}; + +let currentlyRenderingComponent: Object | 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; +// Lazily created map of render-phase updates +let renderPhaseUpdates: Map< + UpdateQueue, + Update, +> | null = null; +// Counter to prevent infinite loops. +let numberOfReRenders: number = 0; +const RE_RENDER_LIMIT = 25; + +function resolveCurrentlyRenderingComponent(): Object { + invariant( + currentlyRenderingComponent !== null, + 'Hooks can only be called inside the body of a function component.', + ); + return currentlyRenderingComponent; +} + +function createHook(): Hook { + return { + memoizedState: null, + queue: null, + next: null, + }; +} + +function createWorkInProgressHook(): Hook { + if (workInProgressHook === null) { + // This is the first hook in the list + if (firstWorkInProgressHook === null) { + isReRender = false; + firstWorkInProgressHook = workInProgressHook = createHook(); + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + workInProgressHook = firstWorkInProgressHook; + } + } else { + if (workInProgressHook.next === null) { + isReRender = false; + // Append to the end of the list + workInProgressHook = workInProgressHook.next = createHook(); + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + workInProgressHook = workInProgressHook.next; + } + } + return workInProgressHook; +} + +export function prepareToUseHooks(componentIdentity: Object): void { + currentlyRenderingComponent = componentIdentity; + + // The following should have already been reset + // didScheduleRenderPhaseUpdate = false; + // firstWorkInProgressHook = null; + // numberOfReRenders = 0; + // renderPhaseUpdates = null; + // workInProgressHook = null; +} + +export function finishHooks( + Component: any, + props: any, + children: any, + refOrContext: any, +): any { + // This must be called after every function component to prevent hooks from + // being used in classes. + + while (didScheduleRenderPhaseUpdate) { + // Updates were scheduled during the render phase. They are stored in + // the `renderPhaseUpdates` map. Call the component again, reusing the + // work-in-progress hooks and applying the additional updates on top. Keep + // restarting until no more updates are scheduled. + didScheduleRenderPhaseUpdate = false; + numberOfReRenders += 1; + + // Start over from the beginning of the list + workInProgressHook = null; + + children = Component(props, refOrContext); + } + currentlyRenderingComponent = null; + firstWorkInProgressHook = null; + numberOfReRenders = 0; + renderPhaseUpdates = null; + workInProgressHook = null; + + // These were reset above + // currentlyRenderingComponent = null; + // didScheduleRenderPhaseUpdate = false; + // firstWorkInProgressHook = null; + // numberOfReRenders = 0; + // renderPhaseUpdates = null; + // workInProgressHook = null; + + return children; +} + +function readContext( + context: ReactContext, + observedBits: void | number | boolean, +): T { + return context._currentValue; +} + +function useContext( + context: ReactContext, + observedBits: void | number | boolean, +): T { + resolveCurrentlyRenderingComponent(); + return context._currentValue; +} + +function basicStateReducer(state: S, action: BasicStateAction): S { + return typeof action === 'function' ? action(state) : action; +} + +export function useState( + initialState: (() => S) | S, +): [S, Dispatch>] { + return useReducer( + basicStateReducer, + // useReducer has a special case to support lazy useState initializers + (initialState: any), + ); +} + +export function useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, +): [S, Dispatch] { + currentlyRenderingComponent = resolveCurrentlyRenderingComponent(); + workInProgressHook = createWorkInProgressHook(); + if (isReRender) { + // This is a re-render. Apply the new render phase updates to the previous + // current hook. + const queue: UpdateQueue = (workInProgressHook.queue: any); + const dispatch: Dispatch = (queue.dispatch: any); + if (renderPhaseUpdates !== null) { + // Render phase updates are stored in a map of queue -> linked list + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate !== undefined) { + renderPhaseUpdates.delete(queue); + let newState = workInProgressHook.memoizedState; + let update = firstRenderPhaseUpdate; + do { + // Process this render phase update. We don't have to check the + // priority because it will always be the same as the current + // render's. + const action = update.action; + newState = reducer(newState, action); + update = update.next; + } while (update !== null); + + workInProgressHook.memoizedState = newState; + + return [newState, dispatch]; + } + } + return [workInProgressHook.memoizedState, dispatch]; + } else { + if (reducer === basicStateReducer) { + // Special case for `useState`. + if (typeof initialState === 'function') { + initialState = initialState(); + } + } else if (initialAction !== undefined && initialAction !== null) { + initialState = reducer(initialState, initialAction); + } + workInProgressHook.memoizedState = initialState; + const queue: UpdateQueue = (workInProgressHook.queue = { + last: null, + dispatch: null, + }); + const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( + null, + currentlyRenderingComponent, + queue, + ): any)); + return [workInProgressHook.memoizedState, dispatch]; + } +} + +function useMemo( + nextCreate: () => T, + inputs: Array | void | null, +): T { + currentlyRenderingComponent = resolveCurrentlyRenderingComponent(); + workInProgressHook = createWorkInProgressHook(); + + const nextInputs = + inputs !== undefined && inputs !== null ? inputs : [nextCreate]; + + if ( + workInProgressHook !== null && + workInProgressHook.memoizedState !== null + ) { + const prevState = workInProgressHook.memoizedState; + const prevInputs = prevState[1]; + if (inputsAreEqual(nextInputs, prevInputs)) { + return prevState[0]; + } + } + + const nextValue = nextCreate(); + workInProgressHook.memoizedState = [nextValue, nextInputs]; + return nextValue; +} + +function useRef(initialValue: T): {current: T} { + currentlyRenderingComponent = resolveCurrentlyRenderingComponent(); + workInProgressHook = createWorkInProgressHook(); + const previousRef = workInProgressHook.memoizedState; + if (previousRef === null) { + const ref = {current: initialValue}; + if (__DEV__) { + Object.seal(ref); + } + workInProgressHook.memoizedState = ref; + return ref; + } else { + return previousRef; + } +} + +function useMutationEffect( + create: () => mixed, + inputs: Array | void | null, +) { + warning( + false, + 'useMutationEffect does nothing on the server, because its effect cannot ' + + "be encoded into the server renderer's output format. This will lead " + + 'to a mismatch between the initial, non-hydrated UI and the intended ' + + 'UI. To avoid this, useMutationEffect should only be used in ' + + 'components that render exclusively on the client.', + ); +} + +export function useLayoutEffect( + create: () => mixed, + inputs: Array | void | null, +) { + warning( + false, + 'useLayoutEffect does nothing on the server, because its effect cannot ' + + "be encoded into the server renderer's output format. This will lead " + + 'to a mismatch between the initial, non-hydrated UI and the intended ' + + 'UI. To avoid this, useLayoutEffect should only be used in ' + + 'components that render exclusively on the client.', + ); +} + +function dispatchAction( + componentIdentity: Object, + queue: UpdateQueue, + action: A, +) { + invariant( + numberOfReRenders < RE_RENDER_LIMIT, + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + + if (componentIdentity === currentlyRenderingComponent) { + // This is a render phase update. Stash it in a lazily-created map of + // queue -> linked list of updates. After this render pass, we'll restart + // and apply the stashed updates on top of the work-in-progress hook. + didScheduleRenderPhaseUpdate = true; + const update: Update = { + action, + next: null, + }; + if (renderPhaseUpdates === null) { + renderPhaseUpdates = new Map(); + } + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate === undefined) { + renderPhaseUpdates.set(queue, update); + } else { + // Append the update to the end of the list. + let lastRenderPhaseUpdate = firstRenderPhaseUpdate; + while (lastRenderPhaseUpdate.next !== null) { + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next; + } + lastRenderPhaseUpdate.next = update; + } + } else { + // This means an update has happened after the function component has + // returned. On the server this is a no-op. In React Fiber, the update + // would be scheduled for a future render. + } +} + +function inputsAreEqual(arr1, arr2) { + // Don't bother comparing lengths because these arrays are always + // passed inline. + for (let i = 0; i < arr1.length; i++) { + // Inlined Object.is polyfill. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + const val1 = arr1[i]; + const val2 = arr2[i]; + if ( + (val1 === val2 && (val1 !== 0 || 1 / val1 === 1 / (val2: any))) || + (val1 !== val1 && val2 !== val2) // eslint-disable-line no-self-compare + ) { + continue; + } + return false; + } + return true; +} + +function noop(): void {} + +export const Dispatcher = { + readContext, + useContext, + useMemo, + useReducer, + useRef, + useState, + useMutationEffect, + useLayoutEffect, + // useImperativeMethods is not run in the server environment + useImperativeMethods: noop, + // Callbacks are not run in the server environment. + useCallback: noop, + // Effects are not run in the server environment. + useEffect: noop, +}; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 5358a69310020..051729a0d8328 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -79,6 +79,7 @@ import { prepareToReadContext, calculateChangedBits, } from './ReactFiberNewContext'; +import {prepareToUseHooks, finishHooks, resetHooks} from './ReactFiberHooks'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; import { getMaskedContext, @@ -193,27 +194,17 @@ function forceUnmountCurrentAndReconcile( function updateForwardRef( current: Fiber | null, workInProgress: Fiber, - type: any, + Component: any, nextProps: any, renderExpirationTime: ExpirationTime, ) { - const render = type.render; + const render = Component.render; const ref = workInProgress.ref; - if (hasLegacyContextChanged()) { - // Normally we can bail out on props equality but if context has changed - // we don't do the bailout and we have to reuse existing props instead. - } else if (workInProgress.memoizedProps === nextProps) { - const currentRef = current !== null ? current.ref : null; - if (ref === currentRef) { - return bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderExpirationTime, - ); - } - } + // The rest is a fork of updateFunctionComponent let nextChildren; + prepareToReadContext(workInProgress, renderExpirationTime); + prepareToUseHooks(current, workInProgress, renderExpirationTime); if (__DEV__) { ReactCurrentOwner.current = workInProgress; ReactCurrentFiber.setCurrentPhase('render'); @@ -222,7 +213,10 @@ function updateForwardRef( } else { nextChildren = render(nextProps, ref); } + nextChildren = finishHooks(render, nextProps, nextChildren, ref); + // React DevTools reads this flag. + workInProgress.effectTag |= PerformedWork; reconcileChildren( current, workInProgress, @@ -406,6 +400,7 @@ function updateFunctionComponent( let nextChildren; prepareToReadContext(workInProgress, renderExpirationTime); + prepareToUseHooks(current, workInProgress, renderExpirationTime); if (__DEV__) { ReactCurrentOwner.current = workInProgress; ReactCurrentFiber.setCurrentPhase('render'); @@ -414,6 +409,7 @@ function updateFunctionComponent( } else { nextChildren = Component(nextProps, context); } + nextChildren = finishHooks(Component, nextProps, nextChildren, context); // React DevTools reads this flag. workInProgress.effectTag |= PerformedWork; @@ -921,6 +917,7 @@ function mountIndeterminateComponent( const context = getMaskedContext(workInProgress, unmaskedContext); prepareToReadContext(workInProgress, renderExpirationTime); + prepareToUseHooks(null, workInProgress, renderExpirationTime); let value; @@ -964,6 +961,9 @@ function mountIndeterminateComponent( // Proceed under the assumption that this is a class instance workInProgress.tag = ClassComponent; + // Throw out any hooks that were used. + resetHooks(); + // Push context providers early to prevent context stack mismatches. // During mounting we don't know the child context yet as the instance doesn't exist. // We will invalidate the child context in finishClassComponent() right after rendering. @@ -1001,6 +1001,7 @@ function mountIndeterminateComponent( } else { // Proceed under the assumption that this is a function component workInProgress.tag = FunctionComponent; + value = finishHooks(Component, props, value, context); if (__DEV__) { if (Component) { warningWithoutStack( diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index 57114af3e9f58..9a167e6f4c8ca 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.js @@ -51,6 +51,7 @@ import { requestCurrentTime, computeExpirationForFiber, scheduleWork, + flushPassiveEffects, } from './ReactFiberScheduler'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -199,6 +200,7 @@ const classComponentUpdater = { update.callback = callback; } + flushPassiveEffects(); enqueueUpdate(fiber, update); scheduleWork(fiber, expirationTime); }, @@ -218,6 +220,7 @@ const classComponentUpdater = { update.callback = callback; } + flushPassiveEffects(); enqueueUpdate(fiber, update); scheduleWork(fiber, expirationTime); }, @@ -236,6 +239,7 @@ const classComponentUpdater = { update.callback = callback; } + flushPassiveEffects(); enqueueUpdate(fiber, update); scheduleWork(fiber, expirationTime); }, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 60662d2388f3e..2f76b6ff50ebf 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -19,12 +19,15 @@ import type {FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {CapturedValue, CapturedError} from './ReactCapturedValue'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import type {FunctionComponentUpdateQueue} from './ReactFiberHooks'; import { enableSchedulerTracing, enableProfilerTimer, } from 'shared/ReactFeatureFlags'; import { + FunctionComponent, + ForwardRef, ClassComponent, HostRoot, HostComponent, @@ -33,6 +36,8 @@ import { Profiler, SuspenseComponent, IncompleteClassComponent, + MemoComponent, + SimpleMemoComponent, } from 'shared/ReactWorkTags'; import { invokeGuardedCallback, @@ -80,9 +85,20 @@ import { } from './ReactFiberHostConfig'; import { captureCommitPhaseError, + flushPassiveEffects, requestCurrentTime, scheduleWork, } from './ReactFiberScheduler'; +import { + NoEffect as NoHookEffect, + UnmountSnapshot, + UnmountMutation, + MountMutation, + UnmountLayout, + MountLayout, + UnmountPassive, + MountPassive, +} from './ReactHookEffectTags'; let didWarnAboutUndefinedSnapshotBeforeUpdate: Set | null = null; if (__DEV__) { @@ -180,11 +196,33 @@ function safelyDetachRef(current: Fiber) { } } +function safelyCallDestroy(current, destroy) { + if (__DEV__) { + invokeGuardedCallback(null, destroy, null); + if (hasCaughtError()) { + const error = clearCaughtError(); + captureCommitPhaseError(current, error); + } + } else { + try { + destroy(); + } catch (error) { + captureCommitPhaseError(current, error); + } + } +} + function commitBeforeMutationLifeCycles( current: Fiber | null, finishedWork: Fiber, ): void { switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: + case SimpleMemoComponent: { + commitHookEffectList(UnmountSnapshot, NoHookEffect, finishedWork); + return; + } case ClassComponent: { if (finishedWork.effectTag & Snapshot) { if (current !== null) { @@ -235,6 +273,41 @@ function commitBeforeMutationLifeCycles( } } +function commitHookEffectList( + unmountTag: number, + mountTag: number, + finishedWork: Fiber, +) { + const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); + let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null; + if (lastEffect !== null) { + const firstEffect = lastEffect.next; + let effect = firstEffect; + do { + if ((effect.tag & unmountTag) !== NoHookEffect) { + // Unmount + const destroy = effect.destroy; + effect.destroy = null; + if (destroy !== null) { + destroy(); + } + } + if ((effect.tag & mountTag) !== NoHookEffect) { + // Mount + const create = effect.create; + const destroy = create(); + effect.destroy = typeof destroy === 'function' ? destroy : null; + } + effect = effect.next; + } while (effect !== firstEffect); + } +} + +export function commitPassiveHookEffects(finishedWork: Fiber): void { + commitHookEffectList(UnmountPassive, NoHookEffect, finishedWork); + commitHookEffectList(NoHookEffect, MountPassive, finishedWork); +} + function commitLifeCycles( finishedRoot: FiberRoot, current: Fiber | null, @@ -242,6 +315,27 @@ function commitLifeCycles( committedExpirationTime: ExpirationTime, ): void { switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: + case SimpleMemoComponent: { + commitHookEffectList(UnmountLayout, MountLayout, finishedWork); + const newUpdateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); + if (newUpdateQueue !== null) { + const callbackList = newUpdateQueue.callbackList; + if (callbackList !== null) { + newUpdateQueue.callbackList = null; + for (let i = 0; i < callbackList.length; i++) { + const update = callbackList[i]; + // Assume this is non-null, since otherwise it would not be part + // of the callback list. + const callback: () => mixed = (update.callback: any); + update.callback = null; + callback(); + } + } + } + break; + } case ClassComponent: { const instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { @@ -363,6 +457,7 @@ function commitLifeCycles( timedOutAt: NoWork, }; finishedWork.memoizedState = newState; + flushPassiveEffects(); scheduleWork(finishedWork, Sync); return; } @@ -496,6 +591,27 @@ function commitUnmount(current: Fiber): void { onCommitUnmount(current); switch (current.tag) { + case FunctionComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: { + const updateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any); + if (updateQueue !== null) { + const lastEffect = updateQueue.lastEffect; + if (lastEffect !== null) { + const firstEffect = lastEffect.next; + let effect = firstEffect; + do { + const destroy = effect.destroy; + if (destroy !== null) { + safelyCallDestroy(current, destroy); + } + effect = effect.next; + } while (effect !== firstEffect); + } + } + break; + } case ClassComponent: { safelyDetachRef(current); const instance = current.stateNode; @@ -869,11 +985,28 @@ function commitDeletion(current: Fiber): void { function commitWork(current: Fiber | null, finishedWork: Fiber): void { if (!supportsMutation) { + switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: { + commitHookEffectList(UnmountMutation, MountMutation, finishedWork); + return; + } + } + commitContainer(finishedWork); return; } switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: { + commitHookEffectList(UnmountMutation, MountMutation, finishedWork); + return; + } case ClassComponent: { return; } diff --git a/packages/react-reconciler/src/ReactFiberDispatcher.js b/packages/react-reconciler/src/ReactFiberDispatcher.js index 354539dd79eff..b5f27df334b7f 100644 --- a/packages/react-reconciler/src/ReactFiberDispatcher.js +++ b/packages/react-reconciler/src/ReactFiberDispatcher.js @@ -8,7 +8,29 @@ */ import {readContext} from './ReactFiberNewContext'; +import { + useCallback, + useContext, + useEffect, + useImperativeMethods, + useLayoutEffect, + useMemo, + useMutationEffect, + useReducer, + useRef, + useState, +} from './ReactFiberHooks'; export const Dispatcher = { readContext, + useCallback, + useContext, + useEffect, + useImperativeMethods, + useLayoutEffect, + useMemo, + useMutationEffect, + useReducer, + useRef, + useState, }; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js new file mode 100644 index 0000000000000..a542f6f24487b --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -0,0 +1,779 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root direcreatey of this source tree. + * + * @flow + */ + +import type {ReactContext} from 'shared/ReactTypes'; +import type {Fiber} from './ReactFiber'; +import type {ExpirationTime} from './ReactFiberExpirationTime'; +import type {HookEffectTag} from './ReactHookEffectTags'; + +import {NoWork} from './ReactFiberExpirationTime'; +import {readContext} from './ReactFiberNewContext'; +import { + Snapshot as SnapshotEffect, + Update as UpdateEffect, + Callback as CallbackEffect, + Passive as PassiveEffect, +} from 'shared/ReactSideEffectTags'; +import { + NoEffect as NoHookEffect, + UnmountSnapshot, + UnmountMutation, + MountMutation, + MountLayout, + UnmountPassive, + MountPassive, +} from './ReactHookEffectTags'; +import { + scheduleWork, + computeExpirationForFiber, + flushPassiveEffects, + requestCurrentTime, +} from './ReactFiberScheduler'; + +import invariant from 'shared/invariant'; +import warningWithoutStack from 'shared/warningWithoutStack'; +import {enableDispatchCallback_DEPRECATED} from 'shared/ReactFeatureFlags'; + +type Update = { + expirationTime: ExpirationTime, + action: A, + callback: null | (S => mixed), + next: Update | null, +}; + +type UpdateQueue = { + last: Update | null, + dispatch: any, +}; + +type Hook = { + memoizedState: any, + + baseState: any, + baseUpdate: Update | null, + queue: UpdateQueue | null, + + next: Hook | null, +}; + +type Effect = { + tag: HookEffectTag, + create: () => mixed, + destroy: (() => mixed) | null, + inputs: Array, + next: Effect, +}; + +export type FunctionComponentUpdateQueue = { + callbackList: Array> | null, + lastEffect: Effect | null, +}; + +type BasicStateAction = (S => S) | S; + +type MaybeCallback = void | null | (S => mixed); + +type Dispatch = (A, MaybeCallback) => void; + +// These are set right before calling the component. +let renderExpirationTime: ExpirationTime = NoWork; +// The work-in-progress fiber. I've named it differently to distinguish it from +// the work-in-progress hook. +let currentlyRenderingFiber: Fiber | null = null; + +// Hooks are stored as a linked list on the fiber's memoizedState field. The +// current hook list is the list that belongs to the current fiber. The +// work-in-progress hook list is a new list that will be added to the +// work-in-progress fiber. +let firstCurrentHook: Hook | null = null; +let currentHook: Hook | null = null; +let firstWorkInProgressHook: Hook | null = null; +let workInProgressHook: Hook | null = null; + +let remainingExpirationTime: ExpirationTime = NoWork; +let componentUpdateQueue: FunctionComponentUpdateQueue | null = null; + +// Updates scheduled during render will trigger an immediate re-render at the +// end of the current pass. We can't store these updates on the normal queue, +// because if the work is aborted, they should be discarded. Because this is +// a relatively rare case, we also don't want to add an additional field to +// either the hook or queue object types. So we store them in a lazily create +// map of queue -> render-phase updates, which are discarded once the component +// completes without re-rendering. + +// 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; +// Lazily created map of render-phase updates +let renderPhaseUpdates: Map< + UpdateQueue, + Update, +> | null = null; +// Counter to prevent infinite loops. +let numberOfReRenders: number = 0; +const RE_RENDER_LIMIT = 25; + +function resolveCurrentlyRenderingFiber(): Fiber { + invariant( + currentlyRenderingFiber !== null, + 'Hooks can only be called inside the body of a function component.', + ); + return currentlyRenderingFiber; +} + +export function prepareToUseHooks( + current: Fiber | null, + workInProgress: Fiber, + nextRenderExpirationTime: ExpirationTime, +): void { + renderExpirationTime = nextRenderExpirationTime; + currentlyRenderingFiber = workInProgress; + firstCurrentHook = current !== null ? current.memoizedState : null; + + // The following should have already been reset + // currentHook = null; + // workInProgressHook = null; + + // remainingExpirationTime = NoWork; + // componentUpdateQueue = null; + + // isReRender = false; + // didScheduleRenderPhaseUpdate = false; + // renderPhaseUpdates = null; + // numberOfReRenders = 0; +} + +export function finishHooks( + Component: any, + props: any, + children: any, + refOrContext: any, +): any { + // This must be called after every function component to prevent hooks from + // being used in classes. + + while (didScheduleRenderPhaseUpdate) { + // Updates were scheduled during the render phase. They are stored in + // the `renderPhaseUpdates` map. Call the component again, reusing the + // work-in-progress hooks and applying the additional updates on top. Keep + // restarting until no more updates are scheduled. + didScheduleRenderPhaseUpdate = false; + numberOfReRenders += 1; + + // Start over from the beginning of the list + currentHook = null; + workInProgressHook = null; + componentUpdateQueue = null; + + children = Component(props, refOrContext); + } + renderPhaseUpdates = null; + numberOfReRenders = 0; + + const renderedWork: Fiber = (currentlyRenderingFiber: any); + + renderedWork.memoizedState = firstWorkInProgressHook; + renderedWork.expirationTime = remainingExpirationTime; + renderedWork.updateQueue = (componentUpdateQueue: any); + + const didRenderTooFewHooks = + currentHook !== null && currentHook.next !== null; + + renderExpirationTime = NoWork; + currentlyRenderingFiber = null; + + firstCurrentHook = null; + currentHook = null; + firstWorkInProgressHook = null; + workInProgressHook = null; + + remainingExpirationTime = NoWork; + componentUpdateQueue = null; + + // Always set during createWorkInProgress + // isReRender = false; + + // These were reset above + // didScheduleRenderPhaseUpdate = false; + // renderPhaseUpdates = null; + // numberOfReRenders = 0; + + invariant( + !didRenderTooFewHooks, + 'Rendered fewer hooks than expected. This may be caused by an accidental ' + + 'early return statement.', + ); + + return children; +} + +export function resetHooks(): void { + // This is called instead of `finishHooks` if the component throws. It's also + // called inside mountIndeterminateComponent if we determine the component + // is a module-style component. + renderExpirationTime = NoWork; + currentlyRenderingFiber = null; + + firstCurrentHook = null; + currentHook = null; + firstWorkInProgressHook = null; + workInProgressHook = null; + + remainingExpirationTime = NoWork; + componentUpdateQueue = null; + + // Always set during createWorkInProgress + // isReRender = false; + + didScheduleRenderPhaseUpdate = false; + renderPhaseUpdates = null; + numberOfReRenders = 0; +} + +function createHook(): Hook { + return { + memoizedState: null, + + baseState: null, + queue: null, + baseUpdate: null, + + next: null, + }; +} + +function cloneHook(hook: Hook): Hook { + return { + memoizedState: hook.memoizedState, + + baseState: hook.memoizedState, + queue: hook.queue, + baseUpdate: hook.baseUpdate, + + next: null, + }; +} + +function createWorkInProgressHook(): Hook { + if (workInProgressHook === null) { + // This is the first hook in the list + if (firstWorkInProgressHook === null) { + isReRender = false; + currentHook = firstCurrentHook; + if (currentHook === null) { + // This is a newly mounted hook + workInProgressHook = createHook(); + } else { + // Clone the current hook. + workInProgressHook = cloneHook(currentHook); + } + firstWorkInProgressHook = workInProgressHook; + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + currentHook = firstCurrentHook; + workInProgressHook = firstWorkInProgressHook; + } + } else { + if (workInProgressHook.next === null) { + isReRender = false; + let hook; + if (currentHook === null) { + // This is a newly mounted hook + hook = createHook(); + } else { + currentHook = currentHook.next; + if (currentHook === null) { + // This is a newly mounted hook + hook = createHook(); + } else { + // Clone the current hook. + hook = cloneHook(currentHook); + } + } + // Append to the end of the list + workInProgressHook = workInProgressHook.next = hook; + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + workInProgressHook = workInProgressHook.next; + currentHook = currentHook !== null ? currentHook.next : null; + } + } + return workInProgressHook; +} + +function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { + return { + callbackList: null, + lastEffect: null, + }; +} + +function basicStateReducer(state: S, action: BasicStateAction): S { + return typeof action === 'function' ? action(state) : action; +} + +export function useContext( + context: ReactContext, + observedBits: void | number | boolean, +): T { + // Ensure we're in a function component (class components support only the + // .unstable_read() form) + resolveCurrentlyRenderingFiber(); + return readContext(context, observedBits); +} + +export function useState( + initialState: (() => S) | S, +): [S, Dispatch>] { + return useReducer( + basicStateReducer, + // useReducer has a special case to support lazy useState initializers + (initialState: any), + ); +} + +export function useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, +): [S, Dispatch] { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + let queue: UpdateQueue | null = (workInProgressHook.queue: any); + if (queue !== null) { + // Already have a queue, so this is an update. + if (isReRender) { + // This is a re-render. Apply the new render phase updates to the previous + // work-in-progress hook. + const dispatch: Dispatch = (queue.dispatch: any); + if (renderPhaseUpdates !== null) { + // Render phase updates are stored in a map of queue -> linked list + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate !== undefined) { + renderPhaseUpdates.delete(queue); + let newState = workInProgressHook.memoizedState; + let update = firstRenderPhaseUpdate; + do { + // Process this render phase update. We don't have to check the + // priority because it will always be the same as the current + // render's. + const action = update.action; + newState = reducer(newState, action); + const callback = update.callback; + if (callback !== null) { + pushCallback(currentlyRenderingFiber, update); + } + update = update.next; + } while (update !== null); + + workInProgressHook.memoizedState = newState; + + // Don't persist the state accumlated from the render phase updates to + // the base state unless the queue is empty. + // TODO: Not sure if this is the desired semantics, but it's what we + // do for gDSFP. I can't remember why. + if (workInProgressHook.baseUpdate === queue.last) { + workInProgressHook.baseState = newState; + } + + return [newState, dispatch]; + } + } + return [workInProgressHook.memoizedState, dispatch]; + } + + // The last update in the entire queue + const last = queue.last; + // The last update that is part of the base state. + const baseUpdate = workInProgressHook.baseUpdate; + + // Find the first unprocessed update. + let first; + if (baseUpdate !== null) { + if (last !== null) { + // For the first update, the queue is a circular linked list where + // `queue.last.next = queue.first`. Once the first update commits, and + // the `baseUpdate` is no longer empty, we can unravel the list. + last.next = null; + } + first = baseUpdate.next; + } else { + first = last !== null ? last.next : null; + } + if (first !== null) { + let newState = workInProgressHook.baseState; + let newBaseState = null; + let newBaseUpdate = null; + let prevUpdate = baseUpdate; + let update = first; + let didSkip = false; + do { + const updateExpirationTime = update.expirationTime; + if (updateExpirationTime > renderExpirationTime) { + // Priority is insufficient. Skip this update. If this is the first + // skipped update, the previous update/state is the new base + // update/state. + if (!didSkip) { + didSkip = true; + newBaseUpdate = prevUpdate; + newBaseState = newState; + } + // Update the remaining priority in the queue. + if ( + remainingExpirationTime === NoWork || + updateExpirationTime < remainingExpirationTime + ) { + remainingExpirationTime = updateExpirationTime; + } + } else { + // Process this update. + const action = update.action; + newState = reducer(newState, action); + const callback = update.callback; + if (callback !== null) { + pushCallback(currentlyRenderingFiber, update); + } + } + prevUpdate = update; + update = update.next; + } while (update !== null && update !== first); + + if (!didSkip) { + newBaseUpdate = prevUpdate; + newBaseState = newState; + } + + workInProgressHook.memoizedState = newState; + workInProgressHook.baseUpdate = newBaseUpdate; + workInProgressHook.baseState = newBaseState; + } + + const dispatch: Dispatch = (queue.dispatch: any); + return [workInProgressHook.memoizedState, dispatch]; + } + + // There's no existing queue, so this is the initial render. + if (reducer === basicStateReducer) { + // Special case for `useState`. + if (typeof initialState === 'function') { + initialState = initialState(); + } + } else if (initialAction !== undefined && initialAction !== null) { + initialState = reducer(initialState, initialAction); + } + workInProgressHook.memoizedState = workInProgressHook.baseState = initialState; + queue = workInProgressHook.queue = { + last: null, + dispatch: null, + }; + const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( + null, + currentlyRenderingFiber, + queue, + ): any)); + return [workInProgressHook.memoizedState, dispatch]; +} + +function pushCallback(workInProgress: Fiber, update: Update): void { + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + componentUpdateQueue.callbackList = [update]; + } else { + const callbackList = componentUpdateQueue.callbackList; + if (callbackList === null) { + componentUpdateQueue.callbackList = [update]; + } else { + callbackList.push(update); + } + } + workInProgress.effectTag |= CallbackEffect; +} + +function pushEffect(tag, create, destroy, inputs) { + const effect: Effect = { + tag, + create, + destroy, + inputs, + // Circular + next: (null: any), + }; + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + componentUpdateQueue.lastEffect = effect.next = effect; + } else { + const lastEffect = componentUpdateQueue.lastEffect; + if (lastEffect === null) { + componentUpdateQueue.lastEffect = effect.next = effect; + } else { + const firstEffect = lastEffect.next; + lastEffect.next = effect; + effect.next = firstEffect; + componentUpdateQueue.lastEffect = effect; + } + } + return effect; +} + +export function useRef(initialValue: T): {current: T} { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + let ref; + + if (workInProgressHook.memoizedState === null) { + ref = {current: initialValue}; + if (__DEV__) { + Object.seal(ref); + } + workInProgressHook.memoizedState = ref; + } else { + ref = workInProgressHook.memoizedState; + } + return ref; +} + +export function useMutationEffect( + create: () => mixed, + inputs: Array | void | null, +): void { + useEffectImpl( + SnapshotEffect | UpdateEffect, + UnmountSnapshot | MountMutation, + create, + inputs, + ); +} + +export function useLayoutEffect( + create: () => mixed, + inputs: Array | void | null, +): void { + useEffectImpl(UpdateEffect, UnmountMutation | MountLayout, create, inputs); +} + +export function useEffect( + create: () => mixed, + inputs: Array | void | null, +): void { + useEffectImpl( + UpdateEffect | PassiveEffect, + UnmountPassive | MountPassive, + create, + inputs, + ); +} + +function useEffectImpl(fiberEffectTag, hookEffectTag, create, inputs): void { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + + let nextInputs = inputs !== undefined && inputs !== null ? inputs : [create]; + let destroy = null; + if (currentHook !== null) { + const prevEffect = currentHook.memoizedState; + destroy = prevEffect.destroy; + if (inputsAreEqual(nextInputs, prevEffect.inputs)) { + pushEffect(NoHookEffect, create, destroy, nextInputs); + return; + } + } + + currentlyRenderingFiber.effectTag |= fiberEffectTag; + workInProgressHook.memoizedState = pushEffect( + hookEffectTag, + create, + destroy, + nextInputs, + ); +} + +export function useImperativeMethods( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + inputs: Array | void | null, +): void { + // TODO: If inputs are provided, should we skip comparing the ref itself? + const nextInputs = + inputs !== null && inputs !== undefined + ? inputs.concat([ref]) + : [ref, create]; + + // TODO: I've implemented this on top of useEffect because it's almost the + // same thing, and it would require an equal amount of code. It doesn't seem + // like a common enough use case to justify the additional size. + useEffectImpl( + UpdateEffect, + UnmountMutation | MountLayout, + () => { + if (typeof ref === 'function') { + const refCallback = ref; + const inst = create(); + refCallback(inst); + return () => refCallback(null); + } else if (ref !== null && ref !== undefined) { + const refObject = ref; + const inst = create(); + refObject.current = inst; + return () => { + refObject.current = null; + }; + } + }, + nextInputs, + ); +} + +export function useCallback( + callback: T, + inputs: Array | void | null, +): T { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + + const nextInputs = + inputs !== undefined && inputs !== null ? inputs : [callback]; + + const prevState = workInProgressHook.memoizedState; + if (prevState !== null) { + const prevInputs = prevState[1]; + if (inputsAreEqual(nextInputs, prevInputs)) { + return prevState[0]; + } + } + workInProgressHook.memoizedState = [callback, nextInputs]; + return callback; +} + +export function useMemo( + nextCreate: () => T, + inputs: Array | void | null, +): T { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + + const nextInputs = + inputs !== undefined && inputs !== null ? inputs : [nextCreate]; + + const prevState = workInProgressHook.memoizedState; + if (prevState !== null) { + const prevInputs = prevState[1]; + if (inputsAreEqual(nextInputs, prevInputs)) { + return prevState[0]; + } + } + + const nextValue = nextCreate(); + workInProgressHook.memoizedState = [nextValue, nextInputs]; + return nextValue; +} + +function dispatchAction( + fiber: Fiber, + queue: UpdateQueue, + action: A, + callback: void | null | (S => mixed), +) { + if (enableDispatchCallback_DEPRECATED) { + if (__DEV__) { + if (typeof callback === 'function') { + warningWithoutStack( + false, + 'Update callbacks (the second argument to dispatch/setState) are ' + + 'deprecated. Try useEffect instead.', + ); + } + } + } else { + callback = null; + } + + invariant( + numberOfReRenders < RE_RENDER_LIMIT, + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + + const alternate = fiber.alternate; + if ( + fiber === currentlyRenderingFiber || + (alternate !== null && alternate === currentlyRenderingFiber) + ) { + // This is a render phase update. Stash it in a lazily-created map of + // queue -> linked list of updates. After this render pass, we'll restart + // and apply the stashed updates on top of the work-in-progress hook. + didScheduleRenderPhaseUpdate = true; + const update: Update = { + expirationTime: renderExpirationTime, + action, + callback: callback !== undefined ? callback : null, + next: null, + }; + if (renderPhaseUpdates === null) { + renderPhaseUpdates = new Map(); + } + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate === undefined) { + renderPhaseUpdates.set(queue, update); + } else { + // Append the update to the end of the list. + let lastRenderPhaseUpdate = firstRenderPhaseUpdate; + while (lastRenderPhaseUpdate.next !== null) { + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next; + } + lastRenderPhaseUpdate.next = update; + } + } else { + const currentTime = requestCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); + const update: Update = { + expirationTime, + action, + callback: callback !== undefined ? callback : null, + next: null, + }; + flushPassiveEffects(); + // Append the update to the end of the list. + const last = queue.last; + if (last === null) { + // This is the first update. Create a circular list. + update.next = update; + } else { + const first = last.next; + if (first !== null) { + // Still circular. + update.next = first; + } + last.next = update; + } + queue.last = update; + scheduleWork(fiber, expirationTime); + } +} + +function inputsAreEqual(arr1, arr2) { + // Don't bother comparing lengths because these arrays are always + // passed inline. + for (let i = 0; i < arr1.length; i++) { + // Inlined Object.is polyfill. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + const val1 = arr1[i]; + const val2 = arr2[i]; + if ( + (val1 === val2 && (val1 !== 0 || 1 / val1 === 1 / (val2: any))) || + (val1 !== val1 && val2 !== val2) // eslint-disable-line no-self-compare + ) { + continue; + } + return false; + } + return true; +} diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index d594267a1094e..a61c1a3304905 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -52,6 +52,7 @@ import { syncUpdates, interactiveUpdates, flushInteractiveUpdates, + flushPassiveEffects, } from './ReactFiberScheduler'; import {createUpdate, enqueueUpdate} from './ReactUpdateQueue'; import ReactFiberInstrumentation from './ReactFiberInstrumentation'; @@ -145,9 +146,11 @@ function scheduleRootUpdate( ); update.callback = callback; } - enqueueUpdate(current, update); + flushPassiveEffects(); + enqueueUpdate(current, update); scheduleWork(current, expirationTime); + return expirationTime; } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 34be16bdb2525..b49715971f891 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -12,7 +12,15 @@ import type {Batch, FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {Interaction} from 'scheduler/src/Tracing'; -import {__interactionsRef, __subscriberRef} from 'scheduler/tracing'; +import { + __interactionsRef, + __subscriberRef, + unstable_wrap as Schedule_tracing_wrap, +} from 'scheduler/tracing'; +import { + unstable_scheduleCallback as Schedule_scheduleCallback, + unstable_cancelCallback as Schedule_cancelCallback, +} from 'scheduler'; import { invokeGuardedCallback, hasCaughtError, @@ -34,13 +42,18 @@ import { Ref, Incomplete, HostEffectMask, + Passive, } from 'shared/ReactSideEffectTags'; import { - HostRoot, ClassComponent, HostComponent, ContextProvider, + ForwardRef, + FunctionComponent, HostPortal, + HostRoot, + MemoComponent, + SimpleMemoComponent, } from 'shared/ReactWorkTags'; import { enableSchedulerTracing, @@ -122,6 +135,7 @@ import { popContext as popLegacyContext, } from './ReactFiberContext'; import {popProvider, resetContextDependences} from './ReactFiberNewContext'; +import {resetHooks} from './ReactFiberHooks'; import {popHostContext, popHostContainer} from './ReactFiberHostContext'; import { recordCommitTime, @@ -150,6 +164,7 @@ import { commitLifeCycles, commitAttachRef, commitDetachRef, + commitPassiveHookEffects, } from './ReactFiberCommitWork'; import {Dispatcher} from './ReactFiberDispatcher'; @@ -186,19 +201,21 @@ if (__DEV__) { didWarnSetStateChildContext = false; const didWarnStateUpdateForUnmountedComponent = {}; - warnAboutUpdateOnUnmounted = function(fiber: Fiber) { + warnAboutUpdateOnUnmounted = function(fiber: Fiber, isClass: boolean) { // We show the whole stack but dedupe on the top component's name because // the problematic code almost always lies inside that component. - const componentName = getComponentName(fiber.type) || 'ReactClass'; + const componentName = getComponentName(fiber.type) || 'ReactComponent'; if (didWarnStateUpdateForUnmountedComponent[componentName]) { return; } warningWithoutStack( false, - "Can't call setState (or forceUpdate) on an unmounted component. This " + + "Can't perform a React state update on an unmounted component. This " + 'is a no-op, but it indicates a memory leak in your application. To ' + - 'fix, cancel all subscriptions and asynchronous tasks in the ' + - 'componentWillUnmount method.%s', + 'fix, cancel all subscriptions and asynchronous tasks in %s.%s', + isClass + ? 'the componentWillUnmount method' + : 'a useEffect cleanup function', ReactCurrentFiber.getStackByFiberInDevAndProd(fiber), ); didWarnStateUpdateForUnmountedComponent[componentName] = true; @@ -253,6 +270,9 @@ let nextRenderDidError: boolean = false; let nextEffect: Fiber | null = null; let isCommitting: boolean = false; +let rootWithPendingPassiveEffects: FiberRoot | null = null; +let passiveEffectCallbackHandle: * = null; +let passiveEffectCallback: * = null; let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; @@ -452,8 +472,6 @@ function commitBeforeMutationLifecycles() { commitBeforeMutationLifeCycles(current, nextEffect); } - // Don't cleanup effects yet; - // This will be done by commitAllLifeCycles() nextEffect = nextEffect.nextEffect; } @@ -493,15 +511,55 @@ function commitAllLifeCycles( commitAttachRef(nextEffect); } - const next = nextEffect.nextEffect; - // Ensure that we clean these up so that we don't accidentally keep them. - // I'm not actually sure this matters because we can't reset firstEffect - // and lastEffect since they're on every node, not just the effectful - // ones. So we have to clean everything as we reuse nodes anyway. - nextEffect.nextEffect = null; - // Ensure that we reset the effectTag here so that we can rely on effect - // tags to reason about the current life-cycle. - nextEffect = next; + if (effectTag & Passive) { + rootWithPendingPassiveEffects = finishedRoot; + } + + nextEffect = nextEffect.nextEffect; + } +} + +function commitPassiveEffects(root: FiberRoot, firstEffect: Fiber): void { + rootWithPendingPassiveEffects = null; + passiveEffectCallbackHandle = null; + passiveEffectCallback = null; + + // Set this to true to prevent re-entrancy + const previousIsRendering = isRendering; + isRendering = true; + + let effect = firstEffect; + do { + if (effect.effectTag & Passive) { + let didError = false; + let error; + if (__DEV__) { + invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); + if (hasCaughtError()) { + didError = true; + error = clearCaughtError(); + } + } else { + try { + commitPassiveHookEffects(effect); + } catch (e) { + didError = true; + error = e; + } + } + if (didError) { + captureCommitPhaseError(effect, error); + } + } + effect = effect.nextEffect; + } while (effect !== null); + + isRendering = previousIsRendering; + + // Check if work was scheduled by one of the effects + const rootExpirationTime = root.expirationTime; + if (rootExpirationTime !== NoWork) { + requestWork(root, rootExpirationTime); } } @@ -520,6 +578,15 @@ function markLegacyErrorBoundaryAsFailed(instance: mixed) { } } +function flushPassiveEffects() { + if (passiveEffectCallback !== null) { + Schedule_cancelCallback(passiveEffectCallbackHandle); + // We call the scheduled callback instead of commitPassiveEffects directly + // to ensure tracing works correctly. + passiveEffectCallback(); + } +} + function commitRoot(root: FiberRoot, finishedWork: Fiber): void { isWorking = true; isCommitting = true; @@ -710,6 +777,22 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void { } } + if (firstEffect !== null && rootWithPendingPassiveEffects !== null) { + // This commit included a passive effect. These do not need to fire until + // after the next paint. Schedule an callback to fire them in an async + // event. To ensure serial execution, the callback will be flushed early if + // we enter rootWithPendingPassiveEffects commit phase before then. + let callback = commitPassiveEffects.bind(null, root, firstEffect); + if (enableSchedulerTracing) { + // TODO: Avoid this extra callback by mutating the tracing ref directly, + // like we do at the beginning of commitRoot. I've opted not to do that + // here because that code is still in flux. + callback = Schedule_tracing_wrap(callback); + } + passiveEffectCallbackHandle = Schedule_scheduleCallback(callback); + passiveEffectCallback = callback; + } + isCommitting = false; isWorking = false; stopCommitLifeCyclesTimer(); @@ -1139,6 +1222,9 @@ function renderRoot( 'renderRoot was called recursively. This error is likely caused ' + 'by a bug in React. Please file an issue.', ); + + flushPassiveEffects(); + isWorking = true; ReactCurrentOwner.currentDispatcher = Dispatcher; @@ -1222,6 +1308,9 @@ function renderRoot( try { workLoop(isYieldy); } catch (thrownValue) { + resetContextDependences(); + resetHooks(); + if (nextUnitOfWork === null) { // This is a fatal error. didFatal = true; @@ -1284,6 +1373,7 @@ function renderRoot( isWorking = false; ReactCurrentOwner.currentDispatcher = null; resetContextDependences(); + resetHooks(); // Yield back to main thread. if (didFatal) { @@ -1413,16 +1503,8 @@ function renderRoot( onComplete(root, rootWorkInProgress, expirationTime); } -function dispatch( - sourceFiber: Fiber, - value: mixed, - expirationTime: ExpirationTime, -) { - invariant( - !isWorking || isCommitting, - 'dispatch: Cannot dispatch during the render phase.', - ); - +function captureCommitPhaseError(sourceFiber: Fiber, value: mixed) { + const expirationTime = Sync; let fiber = sourceFiber.return; while (fiber !== null) { switch (fiber.tag) { @@ -1467,10 +1549,6 @@ function dispatch( } } -function captureCommitPhaseError(fiber: Fiber, error: mixed) { - return dispatch(fiber, error, Sync); -} - function computeThreadID( expirationTime: ExpirationTime, interactionThreadID: number, @@ -1677,8 +1755,18 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null { } if (root === null) { - if (__DEV__ && fiber.tag === ClassComponent) { - warnAboutUpdateOnUnmounted(fiber); + if (__DEV__) { + switch (fiber.tag) { + case ClassComponent: + warnAboutUpdateOnUnmounted(fiber, true); + break; + case FunctionComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: + warnAboutUpdateOnUnmounted(fiber, false); + break; + } } return null; } @@ -2500,4 +2588,5 @@ export { interactiveUpdates, flushInteractiveUpdates, computeUniqueAsyncExpiration, + flushPassiveEffects, }; diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 40c814a37d7e1..26ab5bea0f11a 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -248,15 +248,16 @@ function throwException( ); sourceFiber.effectTag &= ~Incomplete; + // We're going to commit this fiber even though it didn't complete. + // But we shouldn't call any lifecycle methods or callbacks. Remove + // all lifecycle effect tags. + sourceFiber.effectTag &= ~LifecycleEffectMask; + if (sourceFiber.tag === ClassComponent) { - // We're going to commit this fiber even though it didn't complete. - // But we shouldn't call any lifecycle methods or callbacks. Remove - // all lifecycle effect tags. - sourceFiber.effectTag &= ~LifecycleEffectMask; const current = sourceFiber.alternate; if (current === null) { // This is a new mount. Change the tag so it's not mistaken for a - // completed component. For example, we should not call + // completed class component. For example, we should not call // componentWillUnmount if it is deleted. sourceFiber.tag = IncompleteClassComponent; } diff --git a/packages/react-reconciler/src/ReactHookEffectTags.js b/packages/react-reconciler/src/ReactHookEffectTags.js new file mode 100644 index 0000000000000..d54df30cf4e73 --- /dev/null +++ b/packages/react-reconciler/src/ReactHookEffectTags.js @@ -0,0 +1,19 @@ +/** + * 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 + */ + +export type HookEffectTag = number; + +export const NoEffect = /* */ 0b00000000; +export const UnmountSnapshot = /* */ 0b00000010; +export const UnmountMutation = /* */ 0b00000100; +export const MountMutation = /* */ 0b00001000; +export const UnmountLayout = /* */ 0b00010000; +export const MountLayout = /* */ 0b00100000; +export const MountPassive = /* */ 0b01000000; +export const UnmountPassive = /* */ 0b10000000; diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js new file mode 100644 index 0000000000000..a505edf7ee17c --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -0,0 +1,1960 @@ +/** + * 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; +let SchedulerTracing; +let useState; +let useReducer; +let useEffect; +let useMutationEffect; +let useLayoutEffect; +let useCallback; +let useMemo; +let useRef; +let useImperativeMethods; +let forwardRef; +let flushPassiveEffects; +let memo; + +describe('ReactHooks', () => { + beforeEach(() => { + jest.resetModules(); + + jest.mock('scheduler', () => { + let scheduledCallbacks = new Map(); + + flushPassiveEffects = () => { + scheduledCallbacks.forEach(cb => { + cb(); + }); + scheduledCallbacks = new Map(); + }; + + return { + unstable_scheduleCallback(callback) { + const handle = {}; + scheduledCallbacks.set(handle, callback); + return handle; + }, + unstable_cancelCallback(handle) { + scheduledCallbacks.delete(handle); + }, + }; + }); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableHooks = true; + ReactFeatureFlags.enableDispatchCallback_DEPRECATED = true; + ReactFeatureFlags.enableSchedulerTracing = true; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + SchedulerTracing = require('scheduler/tracing'); + useState = React.useState; + useReducer = React.useReducer; + useEffect = React.useEffect; + useMutationEffect = React.useMutationEffect; + useLayoutEffect = React.useLayoutEffect; + useCallback = React.useCallback; + useMemo = React.useMemo; + useRef = React.useRef; + useImperativeMethods = React.useImperativeMethods; + forwardRef = React.forwardRef; + memo = React.memo; + }); + + function span(prop) { + return {type: 'span', hidden: false, children: [], prop}; + } + + function Text(props) { + ReactNoop.yield(props.text); + return ; + } + + it('resumes after an interruption', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeMethods(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + + // Initial mount + const counter = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Schedule some updates + counter.current.updateCount(1); + counter.current.updateCount(count => count + 10); + // Partially flush without committing + ReactNoop.flushThrough(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Interrupt with a high priority update + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(ReactNoop.clearYields()).toEqual(['Total: 0']); + + // Resume rendering + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); + }); + + it('throws inside class components', () => { + class BadCounter extends React.Component { + render() { + const [count] = useState(0); + return ; + } + } + ReactNoop.render(); + + expect(() => ReactNoop.flush()).toThrow( + 'Hooks can only be called inside the body of a function component.', + ); + + // Confirm that a subsequent hook works properly. + function GoodCounter(props, ref) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([10]); + }); + + it('throws inside module-style components', () => { + function Counter() { + return { + render() { + const [count] = useState(0); + return ; + }, + }; + } + ReactNoop.render(); + expect(() => ReactNoop.flush()).toThrow( + 'Hooks can only be called inside the body of a function component.', + ); + + // Confirm that a subsequent hook works properly. + function GoodCounter(props) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([10]); + }); + + it('throws when called outside the render phase', () => { + expect(() => useState(0)).toThrow( + 'Hooks can only be called inside the body of a function component.', + ); + }); + + describe('useState', () => { + it('simple mount and update', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeMethods(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.updateCount(1); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + counter.current.updateCount(count => count + 10); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + }); + + it('lazy state initializer', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(() => { + ReactNoop.yield('getInitialState'); + return props.initialState; + }); + useImperativeMethods(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['getInitialState', 'Count: 42']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]); + + counter.current.updateCount(7); + expect(ReactNoop.flush()).toEqual(['Count: 7']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); + }); + + it('multiple states', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + const [label, updateLabel] = useState('Count'); + useImperativeMethods(ref, () => ({updateCount, updateLabel})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.updateCount(7); + expect(ReactNoop.flush()).toEqual(['Count: 7']); + + counter.current.updateLabel('Total'); + expect(ReactNoop.flush()).toEqual(['Total: 7']); + }); + + it('callbacks', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeMethods(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + expect(() => { + counter.current.updateCount(7, count => { + ReactNoop.yield(`Did update count`); + }); + }).toWarnDev( + 'Warning: Update callbacks (the second argument to ' + + 'dispatch/setState) are deprecated. Try useEffect instead.', + {withoutStack: true}, + ); + + expect(ReactNoop.flush()).toEqual(['Count: 7', 'Did update count']); + + // Update twice in the same batch + expect(() => { + counter.current.updateCount(1, () => { + ReactNoop.yield(`Did update count (first callback)`); + }); + }).toWarnDev( + 'Warning: Update callbacks (the second argument to ' + + 'dispatch/setState) are deprecated. Try useEffect instead.', + {withoutStack: true}, + ); + + expect(() => { + counter.current.updateCount(2, () => { + ReactNoop.yield(`Did update count (second callback)`); + }); + }).toWarnDev( + 'Warning: Update callbacks (the second argument to ' + + 'dispatch/setState) are deprecated. Try useEffect instead.', + {withoutStack: true}, + ); + + expect(ReactNoop.flush()).toEqual([ + // Component only renders once + 'Count: 2', + 'Did update count (first callback)', + 'Did update count (second callback)', + ]); + }); + + it('does not fire callbacks more than once when rebasing', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeMethods(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + expect(() => { + counter.current.updateCount(1, count => { + ReactNoop.yield(`Did update count (low pri)`); + }); + }).toWarnDev( + 'Warning: Update callbacks (the second argument to ' + + 'dispatch/setState) are deprecated. Try useEffect instead.', + {withoutStack: true}, + ); + ReactNoop.flushSync(() => { + expect(() => { + counter.current.updateCount(2, count => { + ReactNoop.yield(`Did update count (high pri)`); + }); + }).toWarnDev( + 'Warning: Update callbacks (the second argument to ' + + 'dispatch/setState) are deprecated. Try useEffect instead.', + {withoutStack: true}, + ); + }); + + expect(ReactNoop.clearYields()).toEqual([ + 'Count: 2', + 'Did update count (high pri)', + ]); + // The high-pri update is processed again when we render at low priority, + // but its callback should not fire again. + expect(ReactNoop.flush()).toEqual([ + 'Count: 2', + 'Did update count (low pri)', + ]); + }); + + it('returns the same updater function every time', () => { + let updaters = []; + function Counter() { + const [count, updateCount] = useState(0); + updaters.push(updateCount); + return ; + } + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + updaters[0](1); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + updaters[0](count => count + 10); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + + expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); + }); + + it('warns on set after unmount', () => { + let _updateCount; + function Counter(props, ref) { + const [, updateCount] = useState(0); + _updateCount = updateCount; + return null; + } + + ReactNoop.render(); + ReactNoop.flush(); + ReactNoop.render(null); + ReactNoop.flush(); + expect(() => _updateCount(1)).toWarnDev( + "Warning: Can't perform a React state update on an unmounted " + + 'component. This is a no-op, but it indicates a memory leak in your ' + + 'application. To fix, cancel all subscriptions and asynchronous ' + + 'tasks in a useEffect cleanup function.\n' + + ' in Counter (at **)', + ); + }); + + it('works with memo', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + return ; + } + Counter = memo(Counter); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + _updateCount(1); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + }); + + describe('updates during the render phase', () => { + it('restarts the render function and applies the new updates on top', () => { + function ScrollView({row: newRow}) { + let [isScrollingDown, setIsScrollingDown] = useState(false); + let [row, setRow] = useState(null); + + if (row !== newRow) { + // Row changed since last render. Update isScrollingDown. + setIsScrollingDown(row !== null && newRow > row); + setRow(newRow); + } + + return ; + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + }); + + it('keeps restarting until there are no more new updates', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + ReactNoop.yield('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('updates multiple times within same render function', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 12) { + setCount(c => c + 1); + setCount(c => c + 1); + setCount(c => c + 1); + } + ReactNoop.yield('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // Should increase by three each time + 'Render: 0', + 'Render: 3', + 'Render: 6', + 'Render: 9', + 'Render: 12', + 12, + ]); + expect(ReactNoop.getChildren()).toEqual([span(12)]); + }); + + it('throws after too many iterations', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + setCount(count + 1); + ReactNoop.yield('Render: ' + count); + return ; + } + ReactNoop.render(); + expect(() => ReactNoop.flush()).toThrow( + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + }); + + it('works with useReducer', () => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter({row: newRow}) { + let [count, dispatch] = useReducer(reducer, 0); + if (count < 3) { + dispatch('increment'); + } + ReactNoop.yield('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('uses reducer passed at time of render, not time of dispatch', () => { + // This test is a bit contrived but it demonstrates a subtle edge case. + + // Reducer A increments by 1. Reducer B increments by 10. + function reducerA(state, action) { + switch (action) { + case 'increment': + return state + 1; + case 'reset': + return 0; + } + } + function reducerB(state, action) { + switch (action) { + case 'increment': + return state + 10; + case 'reset': + return 0; + } + } + + function Counter({row: newRow}, ref) { + let [reducer, setReducer] = useState(() => reducerA); + let [count, dispatch] = useReducer(reducer, 0); + useImperativeMethods(ref, () => ({dispatch})); + if (count < 20) { + dispatch('increment'); + // Swap reducers each time we increment + if (reducer === reducerA) { + setReducer(() => reducerB); + } else { + setReducer(() => reducerA); + } + } + ReactNoop.yield('Render: ' + count); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // The count should increase by alternating amounts of 10 and 1 + // until we reach 21. + 'Render: 0', + 'Render: 10', + 'Render: 11', + 'Render: 21', + 21, + ]); + expect(ReactNoop.getChildren()).toEqual([span(21)]); + + // Test that it works on update, too. This time the log is a bit different + // because we started with reducerB instead of reducerA. + counter.current.dispatch('reset'); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 11', + 'Render: 12', + 'Render: 22', + 22, + ]); + expect(ReactNoop.getChildren()).toEqual([span(22)]); + }); + }); + + describe('useReducer', () => { + it('simple mount and update', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeMethods(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.dispatch(INCREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); + }); + + it('accepts an initial action', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INITIALIZE': + return 10; + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } + + const initialAction = 'INITIALIZE'; + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0, initialAction); + useImperativeMethods(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]); + + counter.current.dispatch(INCREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]); + }); + }); + + describe('useEffect', () => { + it('simple mount and update', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did commit [${props.count}]`); + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did commit [0]']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + // Effects are deferred until after the commit + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did commit [1]']); + }); + + it('flushes passive effects even with sibling deletions', () => { + function LayoutEffect(props) { + useLayoutEffect(() => { + ReactNoop.yield(`Layout effect`); + }); + return ; + } + function PassiveEffect(props) { + useEffect(() => { + ReactNoop.yield(`Passive effect`); + }, []); + return ; + } + let passive = ; + ReactNoop.render([, passive]); + expect(ReactNoop.flush()).toEqual(['Layout', 'Passive', 'Layout effect']); + expect(ReactNoop.getChildren()).toEqual([ + span('Layout'), + span('Passive'), + ]); + + // Destroying the first child shouldn't prevent the passive effect from + // being executed + ReactNoop.render([passive]); + expect(ReactNoop.flush()).toEqual(['Passive effect']); + expect(ReactNoop.getChildren()).toEqual([span('Passive')]); + + // (No effects are left to flush.) + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(null); + }); + + it('flushes passive effects even if siblings schedule an update', () => { + function PassiveEffect(props) { + useEffect(() => { + ReactNoop.yield('Passive effect'); + }); + return ; + } + function LayoutEffect(props) { + let [count, setCount] = useState(0); + useLayoutEffect(() => { + // Scheduling work shouldn't interfere with the queued passive effect + if (count === 0) { + setCount(1); + } + ReactNoop.yield('Layout effect ' + count); + }); + return ; + } + ReactNoop.render([, ]); + expect(ReactNoop.flush()).toEqual([ + 'Passive', + 'Layout', + 'Layout effect 0', + 'Passive effect', + 'Layout', + 'Layout effect 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); + }); + + it('flushes passive effects even if siblings schedule a new root', () => { + function PassiveEffect(props) { + useEffect(() => { + ReactNoop.yield('Passive effect'); + }, []); + return ; + } + function LayoutEffect(props) { + useLayoutEffect(() => { + ReactNoop.yield('Layout effect'); + // Scheduling work shouldn't interfere with the queued passive effect + ReactNoop.renderToRootWithID(, 'root2'); + }); + return ; + } + ReactNoop.render([, ]); + expect(ReactNoop.flush()).toEqual([ + 'Passive', + 'Layout', + 'Layout effect', + 'Passive effect', + 'New Root', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); + }); + + it( + 'flushes effects serially by flushing old effects before flushing ' + + "new ones, if they haven't already fired", + () => { + function getCommittedText() { + const children = ReactNoop.getChildren(); + if (children === null) { + return null; + } + return children[0].prop; + } + + function Counter(props) { + useEffect(() => { + ReactNoop.yield( + `Committed state when effect was fired: ${getCommittedText()}`, + ); + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([0]); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + // Before the effects have a chance to flush, schedule another update + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // The previous effect flushes before the reconciliation + 'Committed state when effect was fired: 0', + 1, + ]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Committed state when effect was fired: 1', + ]); + }, + ); + + it('updates have async priority', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect( + () => { + ReactNoop.yield(`Schedule update [${props.count}]`); + updateCount(props.count); + }, + [props.count], + ); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: (empty)']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Schedule update [0]']); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Schedule update [1]']); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + }); + + it('updates have async priority even if effects are flushed early', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect( + () => { + ReactNoop.yield(`Schedule update [${props.count}]`); + updateCount(props.count); + }, + [props.count], + ); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: (empty)']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + // Rendering again should flush the previous commit's effects + ReactNoop.render(); + ReactNoop.flushThrough(['Schedule update [0]', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + flushPassiveEffects(); + expect(ReactNoop.flush()).toEqual(['Schedule update [1]', 'Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + it('flushes serial effects before enqueueing work', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + useEffect(() => { + ReactNoop.yield(`Will set count to 1`); + updateCount(1); + }, []); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Enqueuing this update forces the passive effect to be flushed -- + // updateCount(1) happens first, so 2 wins. + _updateCount(2); + expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + }); + + it('flushes serial effects before enqueueing work (with tracing)', () => { + const onInteractionScheduledWorkCompleted = jest.fn(); + const onWorkCanceled = jest.fn(); + SchedulerTracing.unstable_subscribe({ + onInteractionScheduledWorkCompleted, + onInteractionTraced: jest.fn(), + onWorkCanceled, + onWorkScheduled: jest.fn(), + onWorkStarted: jest.fn(), + onWorkStopped: jest.fn(), + }); + + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + useEffect(() => { + expect(SchedulerTracing.unstable_getCurrent()).toMatchInteractions([ + tracingEvent, + ]); + ReactNoop.yield(`Will set count to 1`); + updateCount(1); + }, []); + return ; + } + + const tracingEvent = {id: 0, name: 'hello', timestamp: 0}; + SchedulerTracing.unstable_trace( + tracingEvent.name, + tracingEvent.timestamp, + () => { + ReactNoop.render(); + }, + ); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0); + + // Enqueuing this update forces the passive effect to be flushed -- + // updateCount(1) happens first, so 2 wins. + _updateCount(2); + expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect(onWorkCanceled).toHaveBeenCalledTimes(0); + }); + + it( + 'in sync mode, useEffect is deferred and updates finish synchronously ' + + '(in a single batch)', + () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect( + () => { + // Update multiple times. These should all be batched together in + // a single render. + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + }, + [props.count], + ); + return ; + } + ReactNoop.renderLegacySyncRoot(); + // Even in sync mode, effects are deferred until after paint + expect(ReactNoop.flush()).toEqual(['Count: (empty)']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + // Now fire the effects + flushPassiveEffects(); + // There were multiple updates, but there should only be a + // single render + expect(ReactNoop.clearYields()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }, + ); + + it('flushSync is not allowed', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect( + () => { + ReactNoop.yield(`Schedule update [${props.count}]`); + ReactNoop.flushSync(() => { + updateCount(props.count); + }); + }, + [props.count], + ); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: (empty)']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + expect(() => { + flushPassiveEffects(); + }).toThrow('flushSync was called from inside a lifecycle method'); + }); + + it('unmounts previous effect', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did create [${props.count}]`); + return () => { + ReactNoop.yield(`Did destroy [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did create [0]']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Did destroy [0]', + 'Did create [1]', + ]); + }); + + it('unmounts on deletion', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did create [${props.count}]`); + return () => { + ReactNoop.yield(`Did destroy [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did create [0]']); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('unmounts on deletion after skipped effect', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did create [${props.count}]`); + return () => { + ReactNoop.yield(`Did destroy [${props.count}]`); + }; + }, []); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did create [0]']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(null); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('skips effect if constructor has not changed', () => { + function effect() { + ReactNoop.yield(`Did mount`); + return () => { + ReactNoop.yield(`Did unmount`); + }; + } + function Counter(props) { + useEffect(effect); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did mount']); + + ReactNoop.render(); + // No effect, because constructor was hoisted outside render + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Did unmount']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('skips effect if inputs have not changed', () => { + function Counter(props) { + const text = `${props.label}: ${props.count}`; + useEffect( + () => { + ReactNoop.yield(`Did create [${text}]`); + return () => { + ReactNoop.yield(`Did destroy [${text}]`); + }; + }, + [props.label, props.count], + ); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Did create [Count: 0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + // Count changed + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Did destroy [Count: 0]', + 'Did create [Count: 1]', + ]); + + ReactNoop.render(); + // Nothing changed, so no effect should have fired + expect(ReactNoop.flush()).toEqual(['Count: 1']); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(null); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(); + // Label changed + expect(ReactNoop.flush()).toEqual(['Total: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Did destroy [Count: 1]', + 'Did create [Total: 1]', + ]); + }); + + it('multiple effects', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did commit 1 [${props.count}]`); + }); + useEffect(() => { + ReactNoop.yield(`Did commit 2 [${props.count}]`); + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Did commit 1 [0]', + 'Did commit 2 [0]', + ]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Did commit 1 [1]', + 'Did commit 2 [1]', + ]); + }); + + it('unmounts all previous effects before creating any new ones', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Mount A [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + ReactNoop.yield(`Mount B [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount B [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Mount A [0]', 'Mount B [0]']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); + }); + + it('handles errors on mount', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Mount A [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + ReactNoop.yield('Oops!'); + throw new Error('Oops!'); + // eslint-disable-next-line no-unreachable + ReactNoop.yield(`Mount B [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount B [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(() => flushPassiveEffects()).toThrow('Oops'); + expect(ReactNoop.clearYields()).toEqual([ + 'Mount A [0]', + 'Oops!', + // Clean up effect A. There's no effect B to clean-up, because it + // never mounted. + 'Unmount A [0]', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('handles errors on update', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Mount A [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + if (props.count === 1) { + ReactNoop.yield('Oops!'); + throw new Error('Oops!'); + } + ReactNoop.yield(`Mount B [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount B [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Mount A [0]', 'Mount B [0]']); + + // This update will trigger an errror + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(() => flushPassiveEffects()).toThrow('Oops'); + expect(ReactNoop.clearYields()).toEqual([ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Oops!', + // Clean up effect A. There's no effect B to clean-up, because it + // never mounted. + 'Unmount A [1]', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('handles errors on unmount', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Mount A [${props.count}]`); + return () => { + ReactNoop.yield('Oops!'); + throw new Error('Oops!'); + // eslint-disable-next-line no-unreachable + ReactNoop.yield(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + ReactNoop.yield(`Mount B [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount B [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Mount A [0]', 'Mount B [0]']); + + // This update will trigger an errror + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(() => flushPassiveEffects()).toThrow('Oops'); + expect(ReactNoop.clearYields()).toEqual([ + 'Oops!', + // B unmounts even though an error was thrown in the previous effect + 'Unmount B [0]', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('works with memo', () => { + function Counter({count}) { + useLayoutEffect(() => { + ReactNoop.yield('Mount: ' + count); + return () => ReactNoop.yield('Unmount: ' + count); + }); + return ; + } + Counter = memo(Counter); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0', 'Mount: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1', 'Unmount: 0', 'Mount: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Unmount: 1']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + }); + + describe('useMutationEffect and useLayoutEffect', () => { + it('fires layout effects after the host has been mutated', () => { + function getCommittedText() { + const children = ReactNoop.getChildren(); + if (children === null) { + return null; + } + return children[0].prop; + } + + function Counter(props) { + useLayoutEffect(() => { + ReactNoop.yield(`Current: ${getCommittedText()}`); + }); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([0, 'Current: 0']); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([1, 'Current: 1']); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + }); + + it('fires mutation effects before layout effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useMutationEffect(() => { + ReactNoop.yield(`Mount mutation [current: ${committedText}]`); + committedText = props.count + ''; + return () => { + ReactNoop.yield(`Unmount mutation [current: ${committedText}]`); + }; + }); + useLayoutEffect(() => { + ReactNoop.yield(`Mount layout [current: ${committedText}]`); + return () => { + ReactNoop.yield(`Unmount layout [current: ${committedText}]`); + }; + }); + useEffect(() => { + ReactNoop.yield(`Mount normal [current: ${committedText}]`); + return () => { + ReactNoop.yield(`Unmount normal [current: ${committedText}]`); + }; + }); + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Mount mutation [current: (empty)]', + 'Mount layout [current: 0]', + ]); + expect(committedText).toEqual('0'); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Mount normal [current: 0]']); + + // Unmount everything + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual([ + 'Unmount mutation [current: 0]', + 'Unmount layout [current: 0]', + 'Unmount normal [current: 0]', + ]); + }); + + it('force flushes passive effects before firing new mutation effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useMutationEffect(() => { + ReactNoop.yield(`Mount mutation [current: ${committedText}]`); + committedText = props.count + ''; + return () => { + ReactNoop.yield(`Unmount mutation [current: ${committedText}]`); + }; + }); + useEffect(() => { + ReactNoop.yield(`Mount normal [current: ${committedText}]`); + return () => { + ReactNoop.yield(`Unmount normal [current: ${committedText}]`); + }; + }); + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Mount mutation [current: (empty)]']); + expect(committedText).toEqual('0'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Mount normal [current: 0]', + 'Unmount mutation [current: 0]', + 'Mount mutation [current: 0]', + ]); + expect(committedText).toEqual('1'); + + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Unmount normal [current: 1]', + 'Mount normal [current: 1]', + ]); + }); + + it('force flushes passive effects before firing new layout effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useLayoutEffect(() => { + // Normally this would go in a mutation effect, but this test + // intentionally omits a mutation effect. + committedText = props.count + ''; + + ReactNoop.yield(`Mount layout [current: ${committedText}]`); + return () => { + ReactNoop.yield(`Unmount layout [current: ${committedText}]`); + }; + }); + useEffect(() => { + ReactNoop.yield(`Mount normal [current: ${committedText}]`); + return () => { + ReactNoop.yield(`Unmount normal [current: ${committedText}]`); + }; + }); + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Mount layout [current: 0]']); + expect(committedText).toEqual('0'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Mount normal [current: 0]', + 'Unmount layout [current: 0]', + 'Mount layout [current: 1]', + ]); + expect(committedText).toEqual('1'); + + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual([ + 'Unmount normal [current: 1]', + 'Mount normal [current: 1]', + ]); + }); + + it('fires all mutation effects before firing any layout effects', () => { + let committedA = '(empty)'; + let committedB = '(empty)'; + + function CounterA(props) { + useMutationEffect(() => { + ReactNoop.yield( + `Mount A mutation [A: ${committedA}, B: ${committedB}]`, + ); + committedA = props.count + ''; + return () => { + ReactNoop.yield( + `Unmount A mutation [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + useLayoutEffect(() => { + ReactNoop.yield( + `Mount layout A [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + ReactNoop.yield( + `Unmount layout A [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + return null; + } + + function CounterB(props) { + useMutationEffect(() => { + ReactNoop.yield( + `Mount B mutation [A: ${committedA}, B: ${committedB}]`, + ); + committedB = props.count + ''; + return () => { + ReactNoop.yield( + `Unmount B mutation [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + useLayoutEffect(() => { + ReactNoop.yield( + `Mount layout B [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + ReactNoop.yield( + `Unmount layout B [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + return null; + } + + ReactNoop.render( + + + + , + ); + expect(ReactNoop.flush()).toEqual([ + // All mutation effects fire before all layout effects + 'Mount A mutation [A: (empty), B: (empty)]', + 'Mount B mutation [A: 0, B: (empty)]', + 'Mount layout A [A: 0, B: 0]', + 'Mount layout B [A: 0, B: 0]', + ]); + expect([committedA, committedB]).toEqual(['0', '0']); + + ReactNoop.render( + + + + , + ); + expect(ReactNoop.flush()).toEqual([ + // Note: This shows that the clean-up function of a layout effect is + // fired in the same phase as the set-up function of a mutation. + 'Unmount A mutation [A: 0, B: 0]', + 'Unmount B mutation [A: 0, B: 0]', + 'Mount A mutation [A: 0, B: 0]', + 'Unmount layout A [A: 1, B: 0]', + 'Mount B mutation [A: 1, B: 0]', + 'Unmount layout B [A: 1, B: 1]', + 'Mount layout A [A: 1, B: 1]', + 'Mount layout B [A: 1, B: 1]', + ]); + expect([committedA, committedB]).toEqual(['1', '1']); + }); + }); + + describe('useCallback', () => { + it('memoizes callback by comparing inputs', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.increment(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useCallback(() => updateCount(c => c + incrementBy), [ + incrementBy, + ]); + return ( + + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Increment', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 0'), + ]); + + button.current.increment(); + expect(ReactNoop.flush()).toEqual([ + // Button should not re-render, because its props haven't changed + // 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + // Increase the increment amount + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // Inputs did change this time + 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + // Callback should have updated + button.current.increment(); + expect(ReactNoop.flush()).toEqual(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 11'), + ]); + }); + }); + + describe('useMemo', () => { + it('memoizes value by comparing to previous inputs', () => { + function CapitalizedText(props) { + const text = props.text; + const capitalizedText = useMemo( + () => { + ReactNoop.yield(`Capitalize '${text}'`); + return text.toUpperCase(); + }, + [text], + ); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(["Capitalize 'hello'", 'HELLO']); + expect(ReactNoop.getChildren()).toEqual([span('HELLO')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(["Capitalize 'hi'", 'HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(["Capitalize 'goodbye'", 'GOODBYE']); + expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); + }); + + it('compares function if no inputs are provided', () => { + function LazyCompute(props) { + const computed = useMemo(props.compute); + return ; + } + + function computeA() { + ReactNoop.yield('compute A'); + return 'A'; + } + + function computeB() { + ReactNoop.yield('compute B'); + return 'B'; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['compute A', 'A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['compute B', 'B']); + }); + + it('should not invoke memoized function during re-renders unless inputs change', () => { + function LazyCompute(props) { + const computed = useMemo(() => props.compute(props.input), [ + props.input, + ]); + const [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + return ; + } + + function compute(val) { + ReactNoop.yield('compute ' + val); + return val; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['compute A', 'A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['compute B', 'B']); + }); + }); + + describe('useRef', () => { + it('creates a ref object initialized with the provided value', () => { + jest.useFakeTimers(); + + function useDebouncedCallback(callback, ms, inputs) { + const timeoutID = useRef(-1); + useEffect(() => { + return function unmount() { + clearTimeout(timeoutID.current); + }; + }, []); + const debouncedCallback = useCallback( + (...args) => { + clearTimeout(timeoutID.current); + timeoutID.current = setTimeout(callback, ms, ...args); + }, + [callback, ms], + ); + return useCallback(debouncedCallback, inputs); + } + + let ping; + function App() { + ping = useDebouncedCallback( + value => { + ReactNoop.yield('ping: ' + value); + }, + 100, + [], + ); + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + + ping(1); + ping(2); + ping(3); + + expect(ReactNoop.flush()).toEqual([]); + + jest.advanceTimersByTime(100); + + expect(ReactNoop.flush()).toEqual(['ping: 3']); + + ping(4); + jest.advanceTimersByTime(20); + ping(5); + ping(6); + jest.advanceTimersByTime(80); + + expect(ReactNoop.flush()).toEqual([]); + + jest.advanceTimersByTime(20); + expect(ReactNoop.flush()).toEqual(['ping: 6']); + }); + + it('should return the same ref during re-renders', () => { + function Counter() { + const ref = useRef('val'); + const [count, setCount] = useState(0); + const [firstRef] = useState(ref); + + if (firstRef !== ref) { + throw new Error('should never change'); + } + + if (count < 3) { + setCount(count + 1); + } + + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['val']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['val']); + }); + }); + + describe('progressive enhancement', () => { + it('mount additional state', () => { + let updateA; + let updateB; + let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + const [_C, _updateC] = useState(0); + C = _C; + updateC = _updateC; + } else { + C = '[not loaded]'; + } + + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 0, B: 0, C: [not loaded]'), + ]); + + updateA(2); + updateB(3); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 0']); + expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); + + updateC(4); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']); + expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + }); + + it('unmount state', () => { + let updateA; + let updateB; + let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + const [_C, _updateC] = useState(0); + C = _C; + updateC = _updateC; + } else { + C = '[not loaded]'; + } + + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: 0']); + expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]); + + updateA(2); + updateB(3); + updateC(4); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']); + expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + ReactNoop.render(); + expect(() => ReactNoop.flush()).toThrow( + 'Rendered fewer hooks than expected. This may be caused by an ' + + 'accidental early return statement.', + ); + }); + + it('unmount effects', () => { + function App(props) { + useEffect(() => { + ReactNoop.yield('Mount A'); + return () => { + ReactNoop.yield('Unmount A'); + }; + }, []); + + if (props.showMore) { + useEffect(() => { + ReactNoop.yield('Mount B'); + return () => { + ReactNoop.yield('Unmount B'); + }; + }, []); + } + + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Mount A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + flushPassiveEffects(); + expect(ReactNoop.clearYields()).toEqual(['Mount B']); + + ReactNoop.render(); + expect(() => ReactNoop.flush()).toThrow( + 'Rendered fewer hooks than expected. This may be caused by an ' + + 'accidental early return statement.', + ); + }); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index 312affa1e818d..4cc8b4b15f9a9 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -12,6 +12,7 @@ let ReactFeatureFlags = require('shared/ReactFeatureFlags'); let React = require('react'); +let useContext; let ReactNoop; let gen; @@ -20,7 +21,9 @@ describe('ReactNewContext', () => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableHooks = true; React = require('react'); + useContext = React.useContext; ReactNoop = require('react-noop-renderer'); gen = require('random-seed'); }); @@ -45,15 +48,31 @@ describe('ReactNewContext', () => { // a suite of tests for a given context consumer implementation. sharedContextTests('Context.Consumer', Context => Context.Consumer); sharedContextTests( - 'readContext(Context) inside function component', + 'useContext inside function component', Context => function Consumer(props) { const observedBits = props.unstable_observedBits; - const contextValue = readContext(Context, observedBits); + const contextValue = useContext(Context, observedBits); const render = props.children; return render(contextValue); }, ); + sharedContextTests('useContext inside forwardRef component', Context => + React.forwardRef(function Consumer(props, ref) { + const observedBits = props.unstable_observedBits; + const contextValue = useContext(Context, observedBits); + const render = props.children; + return render(contextValue); + }), + ); + sharedContextTests('useContext inside memoized function component', Context => + React.memo(function Consumer(props) { + const observedBits = props.unstable_observedBits; + const contextValue = useContext(Context, observedBits); + const render = props.children; + return render(contextValue); + }), + ); sharedContextTests( 'readContext(Context) inside class component', Context => @@ -66,6 +85,18 @@ describe('ReactNewContext', () => { } }, ); + sharedContextTests( + 'readContext(Context) inside pure class component', + Context => + class Consumer extends React.PureComponent { + render() { + const observedBits = this.props.unstable_observedBits; + const contextValue = readContext(Context, observedBits); + const render = this.props.children; + return render(contextValue); + } + }, + ); function sharedContextTests(label, getConsumer) { describe(`reading context with ${label}`, () => { @@ -852,47 +883,6 @@ describe('ReactNewContext', () => { expect(ReactNoop.getChildren()).toEqual([span(2), span(2)]); }); - // Context consumer bails out on propagating "deep" updates when `value` hasn't changed. - // However, it doesn't bail out from rendering if the component above it re-rendered anyway. - // If we bailed out on referential equality, it would be confusing that you - // can call this.setState(), but an autobound render callback "blocked" the update. - // https://github.com/facebook/react/pull/12470#issuecomment-376917711 - it('consumer does not bail out if there were no bailouts above it', () => { - const Context = React.createContext(0); - const Consumer = getConsumer(Context); - - class App extends React.Component { - state = { - text: 'hello', - }; - - renderConsumer = context => { - ReactNoop.yield('App#renderConsumer'); - return ; - }; - - render() { - ReactNoop.yield('App'); - return ( - - {this.renderConsumer} - - ); - } - } - - // Initial mount - let inst; - ReactNoop.render( (inst = ref)} />); - expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); - expect(ReactNoop.getChildren()).toEqual([span('hello')]); - - // Update - inst.setState({text: 'goodbye'}); - expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); - expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); - }); - // This is a regression case for https://github.com/facebook/react/issues/12389. it('does not run into an infinite loop', () => { const Context = React.createContext(null); @@ -1236,9 +1226,99 @@ describe('ReactNewContext', () => { expect(ReactNoop.flush()).toEqual(['Foo: 2, Bar: 2']); expect(ReactNoop.getChildren()).toEqual([span('Foo: 2, Bar: 2')]); }); + + // Context consumer bails out on propagating "deep" updates when `value` hasn't changed. + // However, it doesn't bail out from rendering if the component above it re-rendered anyway. + // If we bailed out on referential equality, it would be confusing that you + // can call this.setState(), but an autobound render callback "blocked" the update. + // https://github.com/facebook/react/pull/12470#issuecomment-376917711 + it('consumer does not bail out if there were no bailouts above it', () => { + const Context = React.createContext(0); + const Consumer = Context.Consumer; + + class App extends React.Component { + state = { + text: 'hello', + }; + + renderConsumer = context => { + ReactNoop.yield('App#renderConsumer'); + return ; + }; + + render() { + ReactNoop.yield('App'); + return ( + + {this.renderConsumer} + + ); + } + } + + // Initial mount + let inst; + ReactNoop.render( (inst = ref)} />); + expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); + expect(ReactNoop.getChildren()).toEqual([span('hello')]); + + // Update + inst.setState({text: 'goodbye'}); + expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); }); describe('readContext', () => { + // Context consumer bails out on propagating "deep" updates when `value` hasn't changed. + // However, it doesn't bail out from rendering if the component above it re-rendered anyway. + // If we bailed out on referential equality, it would be confusing that you + // can call this.setState(), but an autobound render callback "blocked" the update. + // https://github.com/facebook/react/pull/12470#issuecomment-376917711 + it('does not bail out if there were no bailouts above it', () => { + const Context = React.createContext(0); + + class Consumer extends React.Component { + render() { + const contextValue = readContext(Context); + return this.props.children(contextValue); + } + } + + class App extends React.Component { + state = { + text: 'hello', + }; + + renderConsumer = context => { + ReactNoop.yield('App#renderConsumer'); + return ; + }; + + render() { + ReactNoop.yield('App'); + return ( + + {this.renderConsumer} + + ); + } + } + + // Initial mount + let inst; + ReactNoop.render( (inst = ref)} />); + expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); + expect(ReactNoop.getChildren()).toEqual([span('hello')]); + + // Update + inst.setState({text: 'goodbye'}); + expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); + }); + + describe('useContext', () => { it('can use the same context multiple times in the same function', () => { const Context = React.createContext({foo: 0, bar: 0, baz: 0}, (a, b) => { let result = 0; @@ -1264,13 +1344,13 @@ describe('ReactNewContext', () => { } function FooAndBar() { - const {foo} = readContext(Context, 0b001); - const {bar} = readContext(Context, 0b010); + const {foo} = useContext(Context, 0b001); + const {bar} = useContext(Context, 0b010); return ; } function Baz() { - const {baz} = readContext(Context, 0b100); + const {baz} = useContext(Context, 0b100); return ; } @@ -1329,6 +1409,90 @@ describe('ReactNewContext', () => { span('Baz: 2'), ]); }); + + it('throws when used in a class component', () => { + const Context = React.createContext(0); + class Foo extends React.Component { + render() { + return useContext(Context); + } + } + ReactNoop.render(); + expect(ReactNoop.flush).toThrow( + 'Hooks can only be called inside the body of a function component.', + ); + }); + + it('warns when passed a consumer', () => { + const Context = React.createContext(0); + function Foo() { + return useContext(Context.Consumer); + } + ReactNoop.render(); + expect(ReactNoop.flush).toWarnDev( + 'Calling useContext(Context.Consumer) is not supported, may cause bugs, ' + + 'and will be removed in a future major release. ' + + 'Did you mean to call useContext(Context) instead?', + ); + }); + + it('warns when passed a provider', () => { + const Context = React.createContext(0); + function Foo() { + useContext(Context.Provider); + return null; + } + ReactNoop.render(); + expect(ReactNoop.flush).toWarnDev( + 'Calling useContext(Context.Provider) is not supported. ' + + 'Did you mean to call useContext(Context) instead?', + ); + }); + + // Context consumer bails out on propagating "deep" updates when `value` hasn't changed. + // However, it doesn't bail out from rendering if the component above it re-rendered anyway. + // If we bailed out on referential equality, it would be confusing that you + // can call this.setState(), but an autobound render callback "blocked" the update. + // https://github.com/facebook/react/pull/12470#issuecomment-376917711 + it('does not bail out if there were no bailouts above it', () => { + const Context = React.createContext(0); + + function Consumer({children}) { + const contextValue = useContext(Context); + return children(contextValue); + } + + class App extends React.Component { + state = { + text: 'hello', + }; + + renderConsumer = context => { + ReactNoop.yield('App#renderConsumer'); + return ; + }; + + render() { + ReactNoop.yield('App'); + return ( + + {this.renderConsumer} + + ); + } + } + + // Initial mount + let inst; + ReactNoop.render( (inst = ref)} />); + expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); + expect(ReactNoop.getChildren()).toEqual([span('hello')]); + + // Update + inst.setState({text: 'goodbye'}); + expect(ReactNoop.flush()).toEqual(['App', 'App#renderConsumer']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); }); it('unwinds after errors in complete phase', () => { diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index 693c5d301e393..c0803a610d3c7 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -17,6 +17,7 @@ describe('ReactSuspense', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + ReactFeatureFlags.enableHooks = true; React = require('react'); ReactTestRenderer = require('react-test-renderer'); // JestReact = require('jest-react'); @@ -526,6 +527,51 @@ describe('ReactSuspense', () => { expect(root).toMatchRenderedOutput('B'); }); + it('suspends in a component that also contains useEffect', () => { + const {useLayoutEffect} = React; + + function AsyncTextWithEffect(props) { + const text = props.text; + + useLayoutEffect( + () => { + ReactTestRenderer.unstable_yield('Did commit: ' + text); + }, + [text], + ); + + try { + TextResource.read([props.text, props.ms]); + ReactTestRenderer.unstable_yield(text); + return text; + } catch (promise) { + if (typeof promise.then === 'function') { + ReactTestRenderer.unstable_yield(`Suspend! [${text}]`); + } else { + ReactTestRenderer.unstable_yield(`Error! [${text}]`); + } + throw promise; + } + } + + function App({text}) { + return ( + }> + + + ); + } + + ReactTestRenderer.create(); + expect(ReactTestRenderer).toHaveYielded(['Suspend! [A]', 'Loading...']); + jest.advanceTimersByTime(500); + expect(ReactTestRenderer).toHaveYielded([ + 'Promise resolved [A]', + 'A', + 'Did commit: A', + ]); + }); + it('retries when an update is scheduled on a timed out tree', () => { let instance; class Stateful extends React.Component { diff --git a/packages/react/src/React.js b/packages/react/src/React.js index b04aef44cc693..04f3592cedf90 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -13,6 +13,7 @@ import { REACT_STRICT_MODE_TYPE, REACT_SUSPENSE_TYPE, } from 'shared/ReactSymbols'; +import {enableHooks} from 'shared/ReactFeatureFlags'; import {Component, PureComponent} from './ReactBaseClasses'; import {createRef} from './ReactCreateRef'; @@ -27,6 +28,18 @@ import {createContext} from './ReactContext'; import {lazy} from './ReactLazy'; import forwardRef from './forwardRef'; import memo from './memo'; +import { + useCallback, + useContext, + useEffect, + useImperativeMethods, + useLayoutEffect, + useMemo, + useMutationEffect, + useReducer, + useRef, + useState, +} from './ReactHooks'; import { createElementWithValidation, createFactoryWithValidation, @@ -75,4 +88,17 @@ if (enableStableConcurrentModeAPIs) { React.unstable_Profiler = REACT_PROFILER_TYPE; } +if (enableHooks) { + React.useCallback = useCallback; + React.useContext = useContext; + React.useEffect = useEffect; + React.useImperativeMethods = useImperativeMethods; + React.useLayoutEffect = useLayoutEffect; + React.useMemo = useMemo; + React.useMutationEffect = useMutationEffect; + React.useReducer = useReducer; + React.useRef = useRef; + React.useState = useState; +} + export default React; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js new file mode 100644 index 0000000000000..e8667672fd02f --- /dev/null +++ b/packages/react/src/ReactHooks.js @@ -0,0 +1,120 @@ +/** + * 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. + * + * @flow + */ + +import type {ReactContext} from 'shared/ReactTypes'; +import invariant from 'shared/invariant'; +import warning from 'shared/warning'; + +import ReactCurrentOwner from './ReactCurrentOwner'; + +function resolveDispatcher() { + const dispatcher = ReactCurrentOwner.currentDispatcher; + invariant( + dispatcher !== null, + 'Hooks can only be called inside the body of a function component.', + ); + return dispatcher; +} + +export function useContext( + Context: ReactContext, + observedBits: number | boolean | void, +) { + const dispatcher = resolveDispatcher(); + if (__DEV__) { + // TODO: add a more generic warning for invalid values. + if ((Context: any)._context !== undefined) { + const realContext = (Context: any)._context; + // Don't deduplicate because this legitimately causes bugs + // and nobody should be using this in existing code. + if (realContext.Consumer === Context) { + warning( + false, + 'Calling useContext(Context.Consumer) is not supported, may cause bugs, and will be ' + + 'removed in a future major release. Did you mean to call useContext(Context) instead?', + ); + } else if (realContext.Provider === Context) { + warning( + false, + 'Calling useContext(Context.Provider) is not supported. ' + + 'Did you mean to call useContext(Context) instead?', + ); + } + } + } + return dispatcher.useContext(Context, observedBits); +} + +export function useState(initialState: (() => S) | S) { + const dispatcher = resolveDispatcher(); + return dispatcher.useState(initialState); +} + +export function useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useReducer(reducer, initialState, initialAction); +} + +export function useRef(initialValue: T): {current: T} { + const dispatcher = resolveDispatcher(); + return dispatcher.useRef(initialValue); +} + +export function useEffect( + create: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useEffect(create, inputs); +} + +export function useMutationEffect( + create: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useMutationEffect(create, inputs); +} + +export function useLayoutEffect( + create: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useLayoutEffect(create, inputs); +} + +export function useCallback( + callback: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useCallback(callback, inputs); +} + +export function useMemo( + create: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useMemo(create, inputs); +} + +export function useImperativeMethods( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + inputs: Array | void | null, +): void { + const dispatcher = resolveDispatcher(); + return dispatcher.useImperativeMethods(ref, create, inputs); +} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ada19d784e042..e41d9c89961ad 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -9,6 +9,8 @@ export const enableUserTimingAPI = __DEV__; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; // Helps identify side effects in begin-phase lifecycle hooks and setState reducers: export const debugRenderPhaseSideEffects = false; diff --git a/packages/shared/ReactSideEffectTags.js b/packages/shared/ReactSideEffectTags.js index d6a2c6a06b442..5f3c47f8fd593 100644 --- a/packages/shared/ReactSideEffectTags.js +++ b/packages/shared/ReactSideEffectTags.js @@ -10,25 +10,26 @@ export type SideEffectTag = number; // Don't change these two values. They're used by React Dev Tools. -export const NoEffect = /* */ 0b00000000000; -export const PerformedWork = /* */ 0b00000000001; +export const NoEffect = /* */ 0b000000000000; +export const PerformedWork = /* */ 0b000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b00000000010; -export const Update = /* */ 0b00000000100; -export const PlacementAndUpdate = /* */ 0b00000000110; -export const Deletion = /* */ 0b00000001000; -export const ContentReset = /* */ 0b00000010000; -export const Callback = /* */ 0b00000100000; -export const DidCapture = /* */ 0b00001000000; -export const Ref = /* */ 0b00010000000; -export const Snapshot = /* */ 0b00100000000; +export const Placement = /* */ 0b000000000010; +export const Update = /* */ 0b000000000100; +export const PlacementAndUpdate = /* */ 0b000000000110; +export const Deletion = /* */ 0b000000001000; +export const ContentReset = /* */ 0b000000010000; +export const Callback = /* */ 0b000000100000; +export const DidCapture = /* */ 0b000001000000; +export const Ref = /* */ 0b000010000000; +export const Snapshot = /* */ 0b000100000000; +export const Passive = /* */ 0b001000000000; -// Update & Callback & Ref & Snapshot -export const LifecycleEffectMask = /* */ 0b00110100100; +// Passive & Update & Callback & Ref & Snapshot +export const LifecycleEffectMask = /* */ 0b001110100100; // Union of all host effects -export const HostEffectMask = /* */ 0b00111111111; +export const HostEffectMask = /* */ 0b001111111111; -export const Incomplete = /* */ 0b01000000000; -export const ShouldCapture = /* */ 0b10000000000; +export const Incomplete = /* */ 0b010000000000; +export const ShouldCapture = /* */ 0b100000000000; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js index af1f294596c22..7449853c3c769 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js @@ -15,6 +15,8 @@ import typeof * as FabricFeatureFlagsType from './ReactFeatureFlags.native-fabri export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; export const warnAboutDeprecatedLifecycles = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js b/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js index 20e5ba6f6f684..05a5d9fb1579e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js @@ -15,6 +15,8 @@ import typeof * as FabricFeatureFlagsType from './ReactFeatureFlags.native-fabri export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; export const warnAboutDeprecatedLifecycles = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 737035b32c5d3..d9dcb280b9aff 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -14,6 +14,8 @@ import typeof * as FeatureFlagsShimType from './ReactFeatureFlags.native-fb'; // Re-export dynamic flags from the fbsource version. export const { + enableHooks, + enableDispatchCallback_DEPRECATED, debugRenderPhaseSideEffects, debugRenderPhaseSideEffectsForStrictMode, warnAboutDeprecatedLifecycles, diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index bd11e8daf7702..88026e476710c 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -14,6 +14,8 @@ import typeof * as FeatureFlagsShimType from './ReactFeatureFlags.native-oss'; export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; export const enableUserTimingAPI = __DEV__; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const warnAboutDeprecatedLifecycles = false; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index 6880b7fa72bec..a9903783147c2 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -15,6 +15,8 @@ import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persiste export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; export const warnAboutDeprecatedLifecycles = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 94ab168cd025b..93e5580665702 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -15,6 +15,8 @@ import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persiste export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; export const warnAboutDeprecatedLifecycles = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index c3f67290eb750..d1e14b3531cb8 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -15,6 +15,8 @@ import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persiste export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; +export const enableHooks = false; +export const enableDispatchCallback_DEPRECATED = false; export const warnAboutDeprecatedLifecycles = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 755d418ed796a..7bc4628a62c5f 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -18,8 +18,12 @@ export const { replayFailedUnitOfWorkWithInvokeGuardedCallback, warnAboutDeprecatedLifecycles, disableInputAttributeSyncing, + enableDispatchCallback_DEPRECATED, } = require('ReactFeatureFlags'); +// The rest of the flags are static for better dead code elimination. +export const enableHooks = false; + // In www, we have experimental support for gathering data // from User Timing API calls in production. By default, we // only emit performance.mark/measure calls in __DEV__. But if diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0e9bb851044f8..b3076d8ceeccb 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -410,6 +410,21 @@ const bundles = [ externals: ['jest-diff'], }, + /******* ESLint Plugin for Hooks (proposal) *******/ + { + label: 'eslint-plugin-react-hooks', + // TODO: it's awkward to create a bundle for this + // but if we don't, the package won't get copied. + // We also can't create just DEV bundle because + // it contains a NODE_ENV check inside. + // We should probably tweak our build process + // to allow "raw" packages that don't get bundled. + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'eslint-plugin-react-hooks', + externals: [], + }, + { label: 'scheduler-tracing', bundleTypes: [