diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3f798c90cdb86..08b500a221857 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -26,7 +26,7 @@ Examples of unacceptable behavior by participants include: advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment -* Publishing other's private information, such as a physical or electronic +* Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting diff --git a/fixtures/fizz/README.md b/fixtures/fizz/README.md index 63acd3ddc545d..7cd118b8947ba 100644 --- a/fixtures/fizz/README.md +++ b/fixtures/fizz/README.md @@ -1,6 +1,6 @@ # Fizz Fixtures -A set of basic tests for Fizz primarily focussed on baseline perfomrance of legacy renderToString and streaming implementations. +A set of basic tests for Fizz primarily focussed on baseline performance of legacy renderToString and streaming implementations. ## Setup diff --git a/fixtures/flight/.env b/fixtures/flight/.env deleted file mode 100644 index 7d910f1484c5e..0000000000000 --- a/fixtures/flight/.env +++ /dev/null @@ -1 +0,0 @@ -SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/fixtures/flight/config/env.js b/fixtures/flight/config/env.js index 56ac7fbd0e75e..ce1d7b78f3822 100644 --- a/fixtures/flight/config/env.js +++ b/fixtures/flight/config/env.js @@ -86,8 +86,6 @@ function getClientEnvironment(publicUrl) { WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, // Whether or not react-refresh is enabled. - // react-refresh is not 100% stable at this time, - // which is why it's disabled by default. // It is defined here so it is available in the webpackHotDevClient. FAST_REFRESH: process.env.FAST_REFRESH !== 'false', } diff --git a/fixtures/flight/config/jest/babelTransform.js b/fixtures/flight/config/jest/babelTransform.js new file mode 100644 index 0000000000000..5b391e4055620 --- /dev/null +++ b/fixtures/flight/config/jest/babelTransform.js @@ -0,0 +1,29 @@ +'use strict'; + +const babelJest = require('babel-jest').default; + +const hasJsxRuntime = (() => { + if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') { + return false; + } + + try { + require.resolve('react/jsx-runtime'); + return true; + } catch (e) { + return false; + } +})(); + +module.exports = babelJest.createTransformer({ + presets: [ + [ + require.resolve('babel-preset-react-app'), + { + runtime: hasJsxRuntime ? 'automatic' : 'classic', + }, + ], + ], + babelrc: false, + configFile: false, +}); diff --git a/fixtures/flight/config/paths.js b/fixtures/flight/config/paths.js index 20c5e12018038..01f2dd1fd295b 100644 --- a/fixtures/flight/config/paths.js +++ b/fixtures/flight/config/paths.js @@ -21,6 +21,8 @@ const publicUrlOrPath = getPublicUrlOrPath( process.env.PUBLIC_URL ); +const buildPath = process.env.BUILD_PATH || 'build'; + const moduleFileExtensions = [ 'web.mjs', 'mjs', @@ -52,7 +54,7 @@ const resolveModule = (resolveFn, filePath) => { module.exports = { dotenv: resolveApp('.env'), appPath: resolveApp('.'), - appBuild: resolveApp('build'), + appBuild: resolveApp(buildPath), appPublic: resolveApp('public'), appHtml: resolveApp('public/index.html'), appIndexJs: resolveModule(resolveApp, 'src/index'), @@ -64,6 +66,8 @@ module.exports = { testsSetup: resolveModule(resolveApp, 'src/setupTests'), proxySetup: resolveApp('src/setupProxy.js'), appNodeModules: resolveApp('node_modules'), + appWebpackCache: resolveApp('node_modules/.cache'), + appTsBuildInfoFile: resolveApp('node_modules/.cache/tsconfig.tsbuildinfo'), swSrc: resolveModule(resolveApp, 'src/service-worker'), publicUrlOrPath, }; diff --git a/fixtures/flight/config/pnpTs.js b/fixtures/flight/config/pnpTs.js deleted file mode 100644 index 71b9c6c3b07a9..0000000000000 --- a/fixtures/flight/config/pnpTs.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -const {resolveModuleName} = require('ts-pnp'); - -exports.resolveModuleName = ( - typescript, - moduleName, - containingFile, - compilerOptions, - resolutionHost -) => { - return resolveModuleName( - moduleName, - containingFile, - compilerOptions, - resolutionHost, - typescript.resolveModuleName - ); -}; - -exports.resolveTypeReferenceDirective = ( - typescript, - moduleName, - containingFile, - compilerOptions, - resolutionHost -) => { - return resolveModuleName( - moduleName, - containingFile, - compilerOptions, - resolutionHost, - typescript.resolveTypeReferenceDirective - ); -}; diff --git a/fixtures/flight/config/webpack.config.js b/fixtures/flight/config/webpack.config.js index abcff50d4d183..05590ff0353ed 100644 --- a/fixtures/flight/config/webpack.config.js +++ b/fixtures/flight/config/webpack.config.js @@ -8,18 +8,15 @@ const fs = require('fs'); const path = require('path'); const webpack = require('webpack'); const resolve = require('resolve'); -const PnpWebpackPlugin = require('pnp-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin'); const TerserPlugin = require('terser-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); -const safePostCssParser = require('postcss-safe-parser'); -const ManifestPlugin = require('webpack-manifest-plugin'); +const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); +const {WebpackManifestPlugin} = require('webpack-manifest-plugin'); const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin'); const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); -const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin'); const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent'); const ESLintPlugin = require('eslint-webpack-plugin'); @@ -27,28 +24,37 @@ const paths = require('./paths'); const modules = require('./modules'); const getClientEnvironment = require('./env'); const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin'); -const ForkTsCheckerWebpackPlugin = require('react-dev-utils/ForkTsCheckerWebpackPlugin'); -const typescriptFormatter = require('react-dev-utils/typescriptFormatter'); +const ForkTsCheckerWebpackPlugin = + process.env.TSC_COMPILE_ON_ERROR === 'true' + ? require('react-dev-utils/ForkTsCheckerWarningWebpackPlugin') + : require('react-dev-utils/ForkTsCheckerWebpackPlugin'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); -const postcssNormalize = require('postcss-normalize'); - -const appPackageJson = require(paths.appPackageJson); +const createEnvironmentHash = require('./webpack/persistentCache/createEnvironmentHash'); // Source maps are resource heavy and can cause out of memory issue for large source files. const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; -const webpackDevClientEntry = require.resolve( - 'react-dev-utils/webpackHotDevClient' +const reactRefreshRuntimeEntry = require.resolve('react-refresh/runtime'); +const reactRefreshWebpackPluginRuntimeEntry = require.resolve( + '@pmmmwh/react-refresh-webpack-plugin' ); -const reactRefreshOverlayEntry = require.resolve( - 'react-dev-utils/refreshOverlayInterop' +const babelRuntimeEntry = require.resolve('babel-preset-react-app'); +const babelRuntimeEntryHelpers = require.resolve( + '@babel/runtime/helpers/esm/assertThisInitialized', + {paths: [babelRuntimeEntry]} ); +const babelRuntimeRegenerator = require.resolve('@babel/runtime/regenerator', { + paths: [babelRuntimeEntry], +}); // Some apps do not need the benefits of saving a web request, so not inlining the chunk // makes for a smoother build process. const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false'; +const emitErrorsAsWarnings = process.env.ESLINT_NO_DEV_ERRORS === 'true'; +const disableESLintPlugin = process.env.DISABLE_ESLINT_PLUGIN === 'true'; + const imageInlineSizeLimit = parseInt( process.env.IMAGE_INLINE_SIZE_LIMIT || '10000' ); @@ -56,6 +62,11 @@ const imageInlineSizeLimit = parseInt( // Check if TypeScript is setup const useTypeScript = fs.existsSync(paths.appTsConfig); +// Check if Tailwind config exists +const useTailwind = fs.existsSync( + path.join(paths.appPath, 'tailwind.config.js') +); + // Get the path to the uncompiled service worker (if it exists). const swSrc = paths.swSrc; @@ -119,22 +130,42 @@ module.exports = function(webpackEnv) { // package.json loader: require.resolve('postcss-loader'), options: { - // Necessary for external CSS imports to work - // https://github.com/facebook/create-react-app/issues/2677 - ident: 'postcss', - plugins: () => [ - require('postcss-flexbugs-fixes'), - require('postcss-preset-env')({ - autoprefixer: { - flexbox: 'no-2009', - }, - stage: 3, - }), - // Adds PostCSS Normalize as the reset css with default options, - // so that it honors browserslist config in package.json - // which in turn let's users customize the target behavior as per their needs. - postcssNormalize(), - ], + postcssOptions: { + // Necessary for external CSS imports to work + // https://github.com/facebook/create-react-app/issues/2677 + ident: 'postcss', + config: false, + plugins: !useTailwind + ? [ + 'postcss-flexbugs-fixes', + [ + 'postcss-preset-env', + { + autoprefixer: { + flexbox: 'no-2009', + }, + stage: 3, + }, + ], + // Adds PostCSS Normalize as the reset css with default options, + // so that it honors browserslist config in package.json + // which in turn let's users customize the target behavior as per their needs. + 'postcss-normalize', + ] + : [ + 'tailwindcss', + 'postcss-flexbugs-fixes', + [ + 'postcss-preset-env', + { + autoprefixer: { + flexbox: 'no-2009', + }, + stage: 3, + }, + ], + ], + }, sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment, }, }, @@ -160,6 +191,9 @@ module.exports = function(webpackEnv) { }; return { + target: ['browserslist'], + // Webpack noise constrained to errors and warnings + stats: 'errors-warnings', mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', // Stop compilation early in production bail: isEnvProduction, @@ -170,34 +204,10 @@ module.exports = function(webpackEnv) { : isEnvDevelopment && 'cheap-module-source-map', // These are the "entry points" to our application. // This means they will be the "root" imports that are included in JS bundle. - entry: - isEnvDevelopment && !shouldUseReactRefresh - ? [ - // Include an alternative client for WebpackDevServer. A client's job is to - // connect to WebpackDevServer by a socket and get notified about changes. - // When you save a file, the client will either apply hot updates (in case - // of CSS changes), or refresh the page (in case of JS changes). When you - // make a syntax error, this client will display a syntax error overlay. - // Note: instead of the default WebpackDevServer client, we use a custom one - // to bring better experience for Create React App users. You can replace - // the line below with these two lines if you prefer the stock client: - // - // require.resolve('webpack-dev-server/client') + '?/', - // require.resolve('webpack/hot/dev-server'), - // - // When using the experimental react-refresh integration, - // the webpack plugin takes care of injecting the dev client for us. - webpackDevClientEntry, - // Finally, this is your app's code: - paths.appIndexJs, - // We include the app code last so that if there is a runtime error during - // initialization, it doesn't blow up the WebpackDevServer client, and - // changing JS code would still trigger a refresh. - ] - : paths.appIndexJs, + entry: paths.appIndexJs, output: { // The build folder. - path: isEnvProduction ? paths.appBuild : undefined, + path: paths.appBuild, // Add /* filename */ comments to generated require()s in the output. pathinfo: isEnvDevelopment, // There will be one main bundle, and one file per asynchronous chunk. @@ -205,12 +215,11 @@ module.exports = function(webpackEnv) { filename: isEnvProduction ? 'static/js/[name].[contenthash:8].js' : isEnvDevelopment && 'static/js/bundle.js', - // TODO: remove this when upgrading to webpack 5 - futureEmitAssets: true, // There are also additional JS chunk files if you use code splitting. chunkFilename: isEnvProduction ? 'static/js/[name].[contenthash:8].chunk.js' : isEnvDevelopment && 'static/js/[name].chunk.js', + assetModuleFilename: 'static/media/[name].[hash][ext]', // webpack uses `publicPath` to determine where the app is being served from. // It requires a trailing slash, or the file assets will get an incorrect path. // We inferred the "public path" (such as / or /my-project) from homepage. @@ -223,12 +232,22 @@ module.exports = function(webpackEnv) { .replace(/\\/g, '/') : isEnvDevelopment && (info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')), - // Prevents conflicts when multiple webpack runtimes (from different apps) - // are used on the same page. - jsonpFunction: `webpackJsonp${appPackageJson.name}`, - // this defaults to 'window', but by setting it to 'this' then - // module chunks which are built will work in web workers as well. - globalObject: 'this', + }, + cache: { + type: 'filesystem', + version: createEnvironmentHash(env.raw), + cacheDirectory: paths.appWebpackCache, + store: 'pack', + buildDependencies: { + defaultWebpack: ['webpack/lib/'], + config: [__filename], + tsconfig: [paths.appTsConfig, paths.appJsConfig].filter(f => + fs.existsSync(f) + ), + }, + }, + infrastructureLogging: { + level: 'none', }, optimization: { minimize: isEnvProduction, @@ -272,41 +291,10 @@ module.exports = function(webpackEnv) { ascii_only: true, }, }, - sourceMap: shouldUseSourceMap, }), // This is only used in production mode - new OptimizeCSSAssetsPlugin({ - cssProcessorOptions: { - parser: safePostCssParser, - map: shouldUseSourceMap - ? { - // `inline: false` forces the sourcemap to be output into a - // separate file - inline: false, - // `annotation: true` appends the sourceMappingURL to the end of - // the css file, helping the browser find the sourcemap - annotation: true, - } - : false, - }, - cssProcessorPluginOptions: { - preset: ['default', {minifyFontValues: {removeQuotes: false}}], - }, - }), + new CssMinimizerPlugin(), ], - // Automatically split vendor and commons - // https://twitter.com/wSokra/status/969633336732905474 - // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366 - splitChunks: { - chunks: 'all', - name: false, - }, - // Keep the runtime chunk separated to enable long term caching - // https://twitter.com/wSokra/status/969679223278505985 - // https://github.com/facebook/create-react-app/issues/5358 - runtimeChunk: { - name: entrypoint => `runtime-${entrypoint.name}`, - }, }, resolve: { // This allows you to set a fallback for where webpack should look for modules. @@ -337,9 +325,6 @@ module.exports = function(webpackEnv) { ...(modules.webpackAliases || {}), }, plugins: [ - // Adds support for installing with Plug'n'Play, leading to faster installs and adding - // guards against forgotten dependencies and such. - PnpWebpackPlugin, // Prevents users from importing files from outside of src/ (or node_modules/). // This often causes confusion because we only process files within src/ with babel. // To fix this, we prevent you from importing files out of src/ -- if you'd like to, @@ -347,22 +332,24 @@ module.exports = function(webpackEnv) { // Make sure your source files are compiled, as they will not be processed in any way. new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson, - reactRefreshOverlayEntry, + reactRefreshRuntimeEntry, + reactRefreshWebpackPluginRuntimeEntry, + babelRuntimeEntry, + babelRuntimeEntryHelpers, + babelRuntimeRegenerator, ]), ], }, - resolveLoader: { - plugins: [ - // Also related to Plug'n'Play, but this time it tells webpack to load its loaders - // from the current package. - PnpWebpackPlugin.moduleLoader(module), - ], - }, module: { strictExportPresence: true, rules: [ - // Disable require.ensure as it's not a standard language feature. - {parser: {requireEnsure: false}}, + // Handle node_modules packages that contain sourcemaps + shouldUseSourceMap && { + enforce: 'pre', + exclude: /@babel(?:\/|\\{1,2})runtime/, + test: /\.(js|mjs|jsx|ts|tsx|css)$/, + loader: require.resolve('source-map-loader'), + }, { // "oneOf" will traverse all following loaders until one will // match the requirements. When no loader matches it will fall @@ -372,11 +359,12 @@ module.exports = function(webpackEnv) { // https://github.com/jshttp/mime-db { test: [/\.avif$/], - loader: require.resolve('url-loader'), - options: { - limit: imageInlineSizeLimit, - mimetype: 'image/avif', - name: 'static/media/[name].[hash:8].[ext]', + type: 'asset', + mimetype: 'image/avif', + parser: { + dataUrlCondition: { + maxSize: imageInlineSizeLimit, + }, }, }, // "url" loader works like "file" loader except that it embeds assets @@ -384,10 +372,37 @@ module.exports = function(webpackEnv) { // A missing `test` is equivalent to a match. { test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], - loader: require.resolve('url-loader'), - options: { - limit: imageInlineSizeLimit, - name: 'static/media/[name].[hash:8].[ext]', + type: 'asset', + parser: { + dataUrlCondition: { + maxSize: imageInlineSizeLimit, + }, + }, + }, + { + test: /\.svg$/, + use: [ + { + loader: require.resolve('@svgr/webpack'), + options: { + prettier: false, + svgo: false, + svgoConfig: { + plugins: [{removeViewBox: false}], + }, + titleProp: true, + ref: true, + }, + }, + { + loader: require.resolve('file-loader'), + options: { + name: 'static/media/[name].[hash].[ext]', + }, + }, + ], + issuer: { + and: [/\.(ts|tsx|js|jsx|md|mdx)$/], }, }, // Process application JS with Babel. @@ -410,17 +425,6 @@ module.exports = function(webpackEnv) { ], plugins: [ - [ - require.resolve('babel-plugin-named-asset-import'), - { - loaderMap: { - svg: { - ReactComponent: - '@svgr/webpack?-svgo,+titleProp,+ref![path]', - }, - }, - }, - ], isEnvDevelopment && shouldUseReactRefresh && require.resolve('react-refresh/babel'), @@ -476,6 +480,9 @@ module.exports = function(webpackEnv) { sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment, + modules: { + mode: 'icss', + }, }), // Don't consider CSS imports dead code even if the // containing package claims to have no side effects. @@ -493,6 +500,7 @@ module.exports = function(webpackEnv) { ? shouldUseSourceMap : isEnvDevelopment, modules: { + mode: 'local', getLocalIdent: getCSSModuleLocalIdent, }, }), @@ -509,6 +517,9 @@ module.exports = function(webpackEnv) { sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment, + modules: { + mode: 'icss', + }, }, 'sass-loader' ), @@ -529,6 +540,7 @@ module.exports = function(webpackEnv) { ? shouldUseSourceMap : isEnvDevelopment, modules: { + mode: 'local', getLocalIdent: getCSSModuleLocalIdent, }, }, @@ -541,21 +553,18 @@ module.exports = function(webpackEnv) { // This loader doesn't use a "test" so it will catch all modules // that fall through the other loaders. { - loader: require.resolve('file-loader'), // Exclude `js` files to keep "css" loader working as it injects // its runtime that would otherwise be processed through "file" loader. // Also exclude `html` and `json` extensions so they get processed // by webpacks internal loaders. - exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/], - options: { - name: 'static/media/[name].[hash:8].[ext]', - }, + exclude: [/^$/, /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/], + type: 'asset/resource', }, // ** STOP ** Are you adding a new loader? // Make sure to add the new loader(s) before the "file" loader. ], }, - ], + ].filter(Boolean), }, plugins: [ // Generates an `index.html` file with the ', + '', + '', + '', + '', + '', + ]); + }); + describe('bootstrapScriptContent escaping', () => { it('the "S" in " { window.__test_outlet = ''; @@ -4194,6 +4275,380 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableFloat + it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <> + a title + + + a body + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + a title + + a body + , + ); + + // Hydrate the same thing on the client. We expect this to still fail because is not a Resource + // and is unmatched on hydration + const errors = []; + ReactDOMClient.hydrateRoot( + document, + <> + <title data-baz="baz">a title + + + a body + + , + { + onRecoverableError: (err, errInfo) => { + errors.push(err.message); + }, + }, + ); + expect(() => { + try { + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toThrow('Invalid insertion of HTML node in #document node.'); + } catch (e) { + console.log('e', e); + } + }).toErrorDev( + [ + 'Warning: Expected server HTML to contain a matching in <#document>.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + 'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>', + ], + {withoutStack: 1}, + ); + expect(errors).toEqual([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + expect(getVisibleChildren(document)).toEqual(); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toThrow('The node to be removed is not a child of this node.'); + }); + + // @gate enableFloat + it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => { + const chunks = []; + writable.on('data', chunk => { + chunks.push(chunk); + }); + + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + first + <Suspense> + <AsyncText text="second" /> + </Suspense> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body>{'first'}</body> + </html>, + ); + + await act(() => { + resolveText('second'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + {'first'} + {'second'} + </body> + </html>, + ); + + expect(chunks.pop()).toEqual('</body></html>'); + }); + + // @gate enableFloat + it('recognizes stylesheet links as attributes during hydration', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <> + <link rel="stylesheet" href="foo" precedence="default" /> + <html> + <head> + <link rel="author" precedence="this is a nonsense prop" /> + </head> + <body>a body</body> + </html> + </>, + ); + pipe(writable); + }); + // precedence for stylesheets is mapped to a valid data attribute that is recognized on the client + // as opting this node into resource semantics. the use of precedence on the author link is just a + // non standard attribute which React allows but is not given any special treatment. + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="default" /> + <link rel="author" precedence="this is a nonsense prop" /> + </head> + <body>a body</body> + </html>, + ); + + // It hydrates successfully + const root = ReactDOMClient.hydrateRoot( + document, + <> + <link rel="stylesheet" href="foo" precedence="default" /> + <html> + <head> + <link rel="author" precedence="this is a nonsense prop" /> + </head> + <body>a body</body> + </html> + </>, + ); + // We manually capture uncaught errors b/c Jest does not play well with errors thrown in + // microtasks after the test completes even when it is expecting to fail (e.g. when the gate is false) + // We need to flush the scheduler at the end even if there was an earlier throw otherwise this test will + // fail even when failure is expected. This is primarily caused by invokeGuardedCallback replaying commit + // phase errors which get rethrown in a microtask + const uncaughtErrors = []; + try { + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="default" /> + <link rel="author" precedence="this is a nonsense prop" /> + </head> + <body>a body</body> + </html>, + ); + } catch (e) { + uncaughtErrors.push(e); + } + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (e) { + uncaughtErrors.push(e); + } + + root.render( + <> + <link rel="stylesheet" href="foo" precedence="default" data-bar="bar" /> + <html> + <head /> + <body>a body</body> + </html> + </>, + ); + try { + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link + rel="stylesheet" + href="foo" + data-rprec="default" + data-bar="bar" + /> + </head> + <body>a body</body> + </html>, + ); + } catch (e) { + uncaughtErrors.push(e); + } + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (e) { + uncaughtErrors.push(e); + } + + if (uncaughtErrors.length > 0) { + throw uncaughtErrors[0]; + } + }); + + // Temporarily this test is expected to fail everywhere. When we have resource hoisting + // it should start to pass and we can adjust the gate accordingly + // @gate false && enableFloat + it('should insert missing resources during hydration', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <body>foo</body> + </html>, + ); + pipe(writable); + }); + + const uncaughtErrors = []; + ReactDOMClient.hydrateRoot( + document, + <> + <link rel="stylesheet" href="foo" precedence="foo" /> + <html> + <head /> + <body>foo</body> + </html> + </>, + ); + try { + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" precedence="foo" /> + </head> + <body>foo</body> + </html>, + ); + } catch (e) { + uncaughtErrors.push(e); + } + + // need to flush again to get the invoke guarded callback error to throw in microtask + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (e) { + uncaughtErrors.push(e); + } + + if (uncaughtErrors.length) { + throw uncaughtErrors[0]; + } + }); + + // @gate experimental && enableFloat + it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => { + gate(flags => { + if (!(__EXPERIMENTAL__ && flags.enableFloat)) { + throw new Error('bailing out of test'); + } + }); + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body>a body</body> + </html>, + ); + pipe(writable); + }); + + const errors = []; + ReactDOMClient.hydrateRoot( + document, + <html> + <head> + <link rel="stylesheet" href="foo" precedence="low" /> + </head> + <body>a body</body> + </html>, + { + onRecoverableError(err, errInfo) { + errors.push(err.message); + }, + }, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + [ + 'Warning: A matching Hydratable Resource was not found in the DOM for <link rel="stylesheet" href="foo">', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + ], + {withoutStack: 1}, + ); + expect(errors).toEqual([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }); + + // @gate experimental && enableFloat + it('should error in dev when rendering more than one resource for a given location (href)', async () => { + gate(flags => { + if (!(__EXPERIMENTAL__ && flags.enableFloat)) { + throw new Error('bailing out of test'); + } + }); + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <> + <link rel="stylesheet" href="foo" precedence="low" /> + <link rel="stylesheet" href="foo" precedence="high" /> + <html> + <head /> + <body>a body</body> + </html> + </>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="low" /> + <link rel="stylesheet" href="foo" data-rprec="high" /> + </head> + <body>a body</body> + </html>, + ); + + const errors = []; + ReactDOMClient.hydrateRoot( + document, + <> + <html> + <head> + <link rel="stylesheet" href="foo" precedence="low" /> + <link rel="stylesheet" href="foo" precedence="high" /> + </head> + <body>a body</body> + </html> + </>, + { + onRecoverableError(err, errInfo) { + errors.push(err.message); + }, + }, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev([ + 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"', + 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"', + ]); + expect(errors).toEqual([]); + }); + describe('text separators', () => { // To force performWork to start before resolving AsyncText but before piping we need to wait until // after scheduleWork which currently uses setImmediate to delay performWork diff --git a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js index 9d6a38188376d..0245cebd0a9b8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js @@ -358,6 +358,7 @@ describe('ReactDOMRoot', () => { ); }); + // @gate !__DEV__ || !enableFloat it('warns if updating a root that has had its contents removed', async () => { const root = ReactDOMClient.createRoot(container); root.render(<div>Hi</div>); diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 93c1534c235b4..8b48bae61a497 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -73,6 +73,7 @@ import { enableTrustedTypesIntegration, enableCustomElementPropertySupport, enableClientRenderFallbackOnTextMismatch, + enableFloat, } from 'shared/ReactFeatureFlags'; import { mediaEventTypes, @@ -257,7 +258,7 @@ export function checkForUnmatchedText( } } -function getOwnerDocumentFromRootContainer( +export function getOwnerDocumentFromRootContainer( rootContainerElement: Element | Document | DocumentFragment, ): Document { return rootContainerElement.nodeType === DOCUMENT_NODE @@ -1018,6 +1019,17 @@ export function diffHydratedProperties( : getPropertyInfo(propKey); if (rawProps[SUPPRESS_HYDRATION_WARNING] === true) { // Don't bother comparing. We're ignoring all these warnings. + } else if ( + enableFloat && + tag === 'link' && + rawProps.rel === 'stylesheet' && + propKey === 'precedence' + ) { + // @TODO this is a temporary rule while we haven't implemented HostResources yet. This is used to allow + // for hydrating Resources (at the moment, stylesheets with a precedence prop) by using a data attribute. + // When we implement HostResources there will be no hydration directly so this code can be deleted + // $FlowFixMe - Should be inferred as not undefined. + extraAttributeNames.delete('data-rprec'); } else if ( propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || propKey === SUPPRESS_HYDRATION_WARNING || diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 12a14df0f1c14..52646306767ce 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -40,6 +40,7 @@ import { warnForDeletedHydratableText, warnForInsertedHydratedElement, warnForInsertedHydratedText, + getOwnerDocumentFromRootContainer, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -64,6 +65,7 @@ import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; import { enableCreateEventHandleAPI, enableScopeAPI, + enableFloat, } from 'shared/ReactFeatureFlags'; import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; @@ -675,6 +677,14 @@ export function clearContainer(container: Container): void { export const supportsHydration = true; +export function isHydratableResource(type: string, props: Props) { + return ( + type === 'link' && + typeof (props: any).precedence === 'string' && + (props: any).rel === 'stylesheet' + ); +} + export function canHydrateInstance( instance: HydratableInstance, type: string, @@ -769,10 +779,25 @@ export function registerSuspenseInstanceRetry( function getNextHydratable(node) { // Skip non-hydratable nodes. - for (; node != null; node = node.nextSibling) { + for (; node != null; node = ((node: any): Node).nextSibling) { const nodeType = node.nodeType; - if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) { - break; + if (enableFloat) { + if (nodeType === ELEMENT_NODE) { + if ( + ((node: any): Element).tagName === 'LINK' && + ((node: any): Element).hasAttribute('data-rprec') + ) { + continue; + } + break; + } + if (nodeType === TEXT_NODE) { + break; + } + } else { + if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) { + break; + } } if (nodeType === COMMENT_NODE) { const nodeData = (node: any).data; @@ -873,6 +898,43 @@ export function hydrateSuspenseInstance( precacheFiberNode(internalInstanceHandle, suspenseInstance); } +export function getMatchingResourceInstance( + type: string, + props: Props, + rootHostContainer: Container, +): ?Instance { + if (enableFloat) { + switch (type) { + case 'link': { + if (typeof (props: any).href !== 'string') { + return null; + } + const selector = `link[rel="stylesheet"][data-rprec][href="${ + (props: any).href + }"]`; + const link = getOwnerDocumentFromRootContainer( + rootHostContainer, + ).querySelector(selector); + if (__DEV__) { + const allLinks = getOwnerDocumentFromRootContainer( + rootHostContainer, + ).querySelectorAll(selector); + if (allLinks.length > 1) { + console.error( + 'Stylesheet resources need a unique representation in the DOM while hydrating' + + ' and more than one matching DOM Node was found. To fix, ensure you are only' + + ' rendering one stylesheet link with an href attribute of "%s".', + (props: any).href, + ); + } + } + return link; + } + } + } + return null; +} + export function getNextHydratableInstanceAfterSuspenseInstance( suspenseInstance: SuspenseInstance, ): null | HydratableInstance { diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index c522d91f21ff7..9fbed21bd1767 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -15,6 +15,7 @@ import type { import {queueExplicitHydrationTarget} from '../events/ReactDOMEventReplaying'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; +import {enableFloat} from 'shared/ReactFeatureFlags'; export type RootType = { render(children: ReactNodeList): void, @@ -118,7 +119,7 @@ ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = functio const container = root.containerInfo; - if (container.nodeType !== COMMENT_NODE) { + if (!enableFloat && container.nodeType !== COMMENT_NODE) { const hostInstance = findHostInstanceWithNoPortals(root.current); if (hostInstance) { if (hostInstance.parentNode !== container) { diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index acfe13e4ebbcc..2f626c922aa1a 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -8,6 +8,7 @@ */ import type {ReactNodeList} from 'shared/ReactTypes'; +import type {BootstrapScriptDescriptor} from './ReactDOMServerFormatConfig'; import ReactVersion from 'shared/ReactVersion'; @@ -28,8 +29,8 @@ type Options = {| namespaceURI?: string, nonce?: string, bootstrapScriptContent?: string, - bootstrapScripts?: Array<string>, - bootstrapModules?: Array<string>, + bootstrapScripts?: Array<string | BootstrapScriptDescriptor>, + bootstrapModules?: Array<string | BootstrapScriptDescriptor>, progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed) => ?string, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 4728a48a0552f..6790c44bccaca 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -9,6 +9,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type {Writable} from 'stream'; +import type {BootstrapScriptDescriptor} from './ReactDOMServerFormatConfig'; import ReactVersion from 'shared/ReactVersion'; @@ -38,8 +39,8 @@ type Options = {| namespaceURI?: string, nonce?: string, bootstrapScriptContent?: string, - bootstrapScripts?: Array<string>, - bootstrapModules?: Array<string>, + bootstrapScripts?: Array<string | BootstrapScriptDescriptor>, + bootstrapModules?: Array<string | BootstrapScriptDescriptor>, progressiveChunkSize?: number, onShellReady?: () => void, onShellError?: (error: mixed) => void, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index 989d2de31ba0d..38e4c948f6b9b 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -8,6 +8,7 @@ */ import type {ReactNodeList} from 'shared/ReactTypes'; +import type {BootstrapScriptDescriptor} from './ReactDOMServerFormatConfig'; import ReactVersion from 'shared/ReactVersion'; @@ -27,8 +28,8 @@ type Options = {| identifierPrefix?: string, namespaceURI?: string, bootstrapScriptContent?: string, - bootstrapScripts?: Array<string>, - bootstrapModules?: Array<string>, + bootstrapScripts?: Array<string | BootstrapScriptDescriptor>, + bootstrapModules?: Array<string | BootstrapScriptDescriptor>, progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed) => ?string, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index e799c21a8b78c..b5f0022d12956 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -8,6 +8,8 @@ */ import type {ReactNodeList} from 'shared/ReactTypes'; +import type {BootstrapScriptDescriptor} from './ReactDOMServerFormatConfig'; + import {Writable, Readable} from 'stream'; import ReactVersion from 'shared/ReactVersion'; @@ -28,8 +30,8 @@ type Options = {| identifierPrefix?: string, namespaceURI?: string, bootstrapScriptContent?: string, - bootstrapScripts?: Array<string>, - bootstrapModules?: Array<string>, + bootstrapScripts?: Array<string | BootstrapScriptDescriptor>, + bootstrapModules?: Array<string | BootstrapScriptDescriptor>, progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed) => ?string, diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index 36c9469d60818..308e35a20b3ae 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -20,6 +20,7 @@ import {Children} from 'react'; import { enableFilterEmptyStringAttributesDOM, enableCustomElementPropertySupport, + enableFloat, } from 'shared/ReactFeatureFlags'; import type { @@ -81,6 +82,7 @@ const endInlineScript = stringToPrecomputedChunk('</script>'); const startScriptSrc = stringToPrecomputedChunk('<script src="'); const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="'); +const scriptIntegirty = stringToPrecomputedChunk('" integrity="'); const endAsyncScript = stringToPrecomputedChunk('" async=""></script>'); /** @@ -103,13 +105,17 @@ const scriptRegex = /(<\/|<)(s)(cript)/gi; const scriptReplacer = (match, prefix, s, suffix) => `${prefix}${s === 's' ? '\\u0073' : '\\u0053'}${suffix}`; +export type BootstrapScriptDescriptor = { + src: string, + integrity?: string, +}; // Allows us to keep track of what we've already written so we can refer back to it. export function createResponseState( identifierPrefix: string | void, nonce: string | void, bootstrapScriptContent: string | void, - bootstrapScripts: Array<string> | void, - bootstrapModules: Array<string> | void, + bootstrapScripts: $ReadOnlyArray<string | BootstrapScriptDescriptor> | void, + bootstrapModules: $ReadOnlyArray<string | BootstrapScriptDescriptor> | void, ): ResponseState { const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix; const inlineScriptWithNonce = @@ -128,20 +134,42 @@ export function createResponseState( } if (bootstrapScripts !== undefined) { for (let i = 0; i < bootstrapScripts.length; i++) { + const scriptConfig = bootstrapScripts[i]; + const src = + typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src; + const integrity = + typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity; bootstrapChunks.push( startScriptSrc, - stringToChunk(escapeTextForBrowser(bootstrapScripts[i])), - endAsyncScript, + stringToChunk(escapeTextForBrowser(src)), ); + if (integrity) { + bootstrapChunks.push( + scriptIntegirty, + stringToChunk(escapeTextForBrowser(integrity)), + ); + } + bootstrapChunks.push(endAsyncScript); } } if (bootstrapModules !== undefined) { for (let i = 0; i < bootstrapModules.length; i++) { + const scriptConfig = bootstrapModules[i]; + const src = + typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src; + const integrity = + typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity; bootstrapChunks.push( startModuleSrc, - stringToChunk(escapeTextForBrowser(bootstrapModules[i])), - endAsyncScript, + stringToChunk(escapeTextForBrowser(src)), ); + if (integrity) { + bootstrapChunks.push( + scriptIntegirty, + stringToChunk(escapeTextForBrowser(integrity)), + ); + } + bootstrapChunks.push(endAsyncScript); } } return { @@ -1056,6 +1084,52 @@ function pushStartTextArea( return null; } +function pushLink( + target: Array<Chunk | PrecomputedChunk>, + props: Object, + responseState: ResponseState, +): ReactNodeList { + const isStylesheet = props.rel === 'stylesheet'; + target.push(startChunkForTag('link')); + + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'children': + case 'dangerouslySetInnerHTML': + throw new Error( + `${'link'} is a self-closing tag and must neither have \`children\` nor ` + + 'use `dangerouslySetInnerHTML`.', + ); + case 'precedence': { + if (isStylesheet) { + if (propValue === true || typeof propValue === 'string') { + pushAttribute(target, responseState, 'data-rprec', propValue); + } else if (__DEV__) { + throw new Error( + `the "precedence" prop for links to stylesheets expects to receive a string but received something of type "${typeof propValue}" instead.`, + ); + } + break; + } + // intentionally fall through + } + // eslint-disable-next-line-no-fallthrough + default: + pushAttribute(target, responseState, propKey, propValue); + break; + } + } + } + + target.push(endOfStartTagSelfClosing); + return null; +} + function pushSelfClosing( target: Array<Chunk | PrecomputedChunk>, props: Object, @@ -1189,6 +1263,39 @@ function pushStartTitle( return children; } +function pushStartHead( + target: Array<Chunk | PrecomputedChunk>, + preamble: Array<Chunk | PrecomputedChunk>, + props: Object, + tag: string, + responseState: ResponseState, +): ReactNodeList { + // Preamble type is nullable for feature off cases but is guaranteed when feature is on + target = enableFloat ? preamble : target; + + return pushStartGenericElement(target, props, tag, responseState); +} + +function pushStartHtml( + target: Array<Chunk | PrecomputedChunk>, + preamble: Array<Chunk | PrecomputedChunk>, + props: Object, + tag: string, + formatContext: FormatContext, + responseState: ResponseState, +): ReactNodeList { + // Preamble type is nullable for feature off cases but is guaranteed when feature is on + target = enableFloat ? preamble : target; + + if (formatContext.insertionMode === ROOT_HTML_MODE) { + // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) + // then we also emit the DOCTYPE as part of the root content as a convenience for + // rendering the whole document. + target.push(DOCTYPE); + } + return pushStartGenericElement(target, props, tag, responseState); +} + function pushStartGenericElement( target: Array<Chunk | PrecomputedChunk>, props: Object, @@ -1405,6 +1512,7 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>'); export function pushStartInstance( target: Array<Chunk | PrecomputedChunk>, + preamble: Array<Chunk | PrecomputedChunk>, type: string, props: Object, responseState: ResponseState, @@ -1461,6 +1569,8 @@ export function pushStartInstance( return pushStartMenuItem(target, props, responseState); case 'title': return pushStartTitle(target, props, responseState); + case 'link': + return pushLink(target, props, responseState); // Newline eating tags case 'listing': case 'pre': { @@ -1475,7 +1585,6 @@ export function pushStartInstance( case 'hr': case 'img': case 'keygen': - case 'link': case 'meta': case 'param': case 'source': @@ -1495,14 +1604,18 @@ export function pushStartInstance( case 'missing-glyph': { return pushStartGenericElement(target, props, type, responseState); } + // Preamble start tags + case 'head': + return pushStartHead(target, preamble, props, type, responseState); case 'html': { - if (formatContext.insertionMode === ROOT_HTML_MODE) { - // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) - // then we also emit the DOCTYPE as part of the root content as a convenience for - // rendering the whole document. - target.push(DOCTYPE); - } - return pushStartGenericElement(target, props, type, responseState); + return pushStartHtml( + target, + preamble, + props, + type, + formatContext, + responseState, + ); } default: { if (type.indexOf('-') === -1 && typeof props.is !== 'string') { @@ -1521,6 +1634,7 @@ const endTag2 = stringToPrecomputedChunk('>'); export function pushEndInstance( target: Array<Chunk | PrecomputedChunk>, + postamble: Array<Chunk | PrecomputedChunk>, type: string, props: Object, ): void { @@ -1546,6 +1660,11 @@ export function pushEndInstance( // No close tag needed. break; } + // Postamble end tags + case 'body': + case 'html': + target = enableFloat ? postamble : target; + // Intentional fallthrough default: { target.push(endTag1, stringToChunk(type), endTag2); } diff --git a/packages/react-native-renderer/src/ReactNativeFiberInspector.js b/packages/react-native-renderer/src/ReactNativeFiberInspector.js index a0f543a9c4e43..f0c6a8de2f566 100644 --- a/packages/react-native-renderer/src/ReactNativeFiberInspector.js +++ b/packages/react-native-renderer/src/ReactNativeFiberInspector.js @@ -217,6 +217,7 @@ if (__DEV__) { pointerY: locationY, frame: {left: pageX, top: pageY, width, height}, touchedViewTag: nativeViewTag, + closestInstance, }); }, ); diff --git a/packages/react-native-renderer/src/ReactNativeTypes.js b/packages/react-native-renderer/src/ReactNativeTypes.js index 864952c52ff24..4d2fdcc6afc3b 100644 --- a/packages/react-native-renderer/src/ReactNativeTypes.js +++ b/packages/react-native-renderer/src/ReactNativeTypes.js @@ -150,6 +150,7 @@ export type InspectorData = $ReadOnly<{| export type TouchedViewDataAtPoint = $ReadOnly<{| pointerY: number, touchedViewTag?: number, + closestInstance?: mixed, frame: $ReadOnly<{| top: number, left: number, diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 3c2c23c911faf..92685d4f85bef 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -137,6 +137,7 @@ export function pushTextInstance( export function pushStartInstance( target: Array<Chunk | PrecomputedChunk>, + preamble: Array<Chunk | PrecomputedChunk>, type: string, props: Object, responseState: ResponseState, @@ -153,6 +154,7 @@ export function pushStartInstance( export function pushEndInstance( target: Array<Chunk | PrecomputedChunk>, + postamble: Array<Chunk | PrecomputedChunk>, type: string, props: Object, ): void { diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 14003b8291b37..e70de39ab3868 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({ }, pushStartInstance( target: Array<Uint8Array>, + preamble: Array<Uint8Array>, type: string, props: Object, ): ReactNodeList { @@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({ pushEndInstance( target: Array<Uint8Array>, + postamble: Array<Uint8Array>, type: string, props: Object, ): void { diff --git a/packages/react-reconciler/README.md b/packages/react-reconciler/README.md index c2f7991acdb2d..102fbf42e141b 100644 --- a/packages/react-reconciler/README.md +++ b/packages/react-reconciler/README.md @@ -199,7 +199,7 @@ You can proxy this to `clearTimeout` or its equivalent in your environment. This is a property (not a function) that should be set to something that can never be a valid timeout ID. For example, you can set it to `-1`. -#### `supportsMicrotask` +#### `supportsMicrotasks` Set this to true to indicate that your renderer supports `scheduleMicrotask`. We use microtasks as part of our discrete event implementation in React DOM. If you're not sure if your renderer should support this, you probably should. The option to not implement `scheduleMicrotask` exists so that platforms with more control over user events, like React Native, can choose to use a different mechanism. #### `scheduleMicrotask(fn)` diff --git a/packages/react-reconciler/src/ReactChildFiber.new.js b/packages/react-reconciler/src/ReactChildFiber.new.js index e7eb67328af09..71971c03892ca 100644 --- a/packages/react-reconciler/src/ReactChildFiber.new.js +++ b/packages/react-reconciler/src/ReactChildFiber.new.js @@ -13,7 +13,12 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.new'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {Placement, ChildDeletion, Forked} from './ReactFiberFlags'; +import { + Placement, + ChildDeletion, + Forked, + PlacementDEV, +} from './ReactFiberFlags'; import { getIteratorFn, REACT_ELEMENT_TYPE, @@ -343,7 +348,7 @@ function ChildReconciler(shouldTrackSideEffects) { const oldIndex = current.index; if (oldIndex < lastPlacedIndex) { // This is a move. - newFiber.flags |= Placement; + newFiber.flags |= Placement | PlacementDEV; return lastPlacedIndex; } else { // This item can stay in place. @@ -351,7 +356,7 @@ function ChildReconciler(shouldTrackSideEffects) { } } else { // This is an insertion. - newFiber.flags |= Placement; + newFiber.flags |= Placement | PlacementDEV; return lastPlacedIndex; } } @@ -360,7 +365,7 @@ function ChildReconciler(shouldTrackSideEffects) { // This is simpler for the single child case. We only need to do a // placement for inserting new children. if (shouldTrackSideEffects && newFiber.alternate === null) { - newFiber.flags |= Placement; + newFiber.flags |= Placement | PlacementDEV; } return newFiber; } diff --git a/packages/react-reconciler/src/ReactChildFiber.old.js b/packages/react-reconciler/src/ReactChildFiber.old.js index 320924d6030ec..86f75fbf65ea8 100644 --- a/packages/react-reconciler/src/ReactChildFiber.old.js +++ b/packages/react-reconciler/src/ReactChildFiber.old.js @@ -13,7 +13,12 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.old'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {Placement, ChildDeletion, Forked} from './ReactFiberFlags'; +import { + Placement, + ChildDeletion, + Forked, + PlacementDEV, +} from './ReactFiberFlags'; import { getIteratorFn, REACT_ELEMENT_TYPE, @@ -343,7 +348,7 @@ function ChildReconciler(shouldTrackSideEffects) { const oldIndex = current.index; if (oldIndex < lastPlacedIndex) { // This is a move. - newFiber.flags |= Placement; + newFiber.flags |= Placement | PlacementDEV; return lastPlacedIndex; } else { // This item can stay in place. @@ -351,7 +356,7 @@ function ChildReconciler(shouldTrackSideEffects) { } } else { // This is an insertion. - newFiber.flags |= Placement; + newFiber.flags |= Placement | PlacementDEV; return lastPlacedIndex; } } @@ -360,7 +365,7 @@ function ChildReconciler(shouldTrackSideEffects) { // This is simpler for the single child case. We only need to do a // placement for inserting new children. if (shouldTrackSideEffects && newFiber.alternate === null) { - newFiber.flags |= Placement; + newFiber.flags |= Placement | PlacementDEV; } return newFiber; } diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 560a3d0ec3321..d6a6c8f7adc19 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -98,6 +98,7 @@ import { REACT_CACHE_TYPE, REACT_TRACING_MARKER_TYPE, } from 'shared/ReactSymbols'; +import {TransitionTracingMarker} from './ReactFiberTracingMarkerComponent.new'; export type {Fiber}; @@ -770,8 +771,11 @@ export function createFiberFromTracingMarker( fiber.elementType = REACT_TRACING_MARKER_TYPE; fiber.lanes = lanes; const tracingMarkerInstance: TracingMarkerInstance = { + tag: TransitionTracingMarker, transitions: null, pendingBoundaries: null, + aborts: null, + name: pendingProps.name, }; fiber.stateNode = tracingMarkerInstance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index 0a29c4dce782f..d8f1ef424bd13 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -98,6 +98,7 @@ import { REACT_CACHE_TYPE, REACT_TRACING_MARKER_TYPE, } from 'shared/ReactSymbols'; +import {TransitionTracingMarker} from './ReactFiberTracingMarkerComponent.old'; export type {Fiber}; @@ -770,8 +771,11 @@ export function createFiberFromTracingMarker( fiber.elementType = REACT_TRACING_MARKER_TYPE; fiber.lanes = lanes; const tracingMarkerInstance: TracingMarkerInstance = { + tag: TransitionTracingMarker, transitions: null, pendingBoundaries: null, + aborts: null, + name: pendingProps.name, }; fiber.stateNode = tracingMarkerInstance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 96d0d2f8d4c16..ff5098fe372e7 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -88,6 +88,7 @@ import { StaticMask, ShouldCapture, ForceClientRender, + Passive, } from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -267,6 +268,7 @@ import { getMarkerInstances, pushMarkerInstance, pushRootMarkerInstance, + TransitionTracingMarker, } from './ReactFiberTracingMarkerComponent.new'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -975,11 +977,19 @@ function updateTracingMarkerComponent( const currentTransitions = getPendingTransitions(); if (currentTransitions !== null) { const markerInstance: TracingMarkerInstance = { + tag: TransitionTracingMarker, transitions: new Set(currentTransitions), - pendingBoundaries: new Map(), + pendingBoundaries: null, name: workInProgress.pendingProps.name, + aborts: null, }; workInProgress.stateNode = markerInstance; + + // We call the marker complete callback when all child suspense boundaries resolve. + // We do this in the commit phase on Offscreen. If the marker has no child suspense + // boundaries, we need to schedule a passive effect to make sure we call the marker + // complete callback. + workInProgress.flags |= Passive; } } else { if (__DEV__) { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 1fa498e29a929..449f306e7ef89 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -89,6 +89,7 @@ import { StaticMask, ShouldCapture, ForceClientRender, + Passive, } from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -268,6 +269,7 @@ import { getMarkerInstances, pushMarkerInstance, pushRootMarkerInstance, + TransitionTracingMarker, } from './ReactFiberTracingMarkerComponent.old'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -976,11 +978,19 @@ function updateTracingMarkerComponent( const currentTransitions = getPendingTransitions(); if (currentTransitions !== null) { const markerInstance: TracingMarkerInstance = { + tag: TransitionTracingMarker, transitions: new Set(currentTransitions), - pendingBoundaries: new Map(), + pendingBoundaries: null, name: workInProgress.pendingProps.name, + aborts: null, }; workInProgress.stateNode = markerInstance; + + // We call the marker complete callback when all child suspense boundaries resolve. + // We do this in the commit phase on Offscreen. If the marker has no child suspense + // boundaries, we need to schedule a passive effect to make sure we call the marker + // complete callback. + workInProgress.flags |= Passive; } } else { if (__DEV__) { diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.new.js b/packages/react-reconciler/src/ReactFiberClassComponent.new.js index c0a3418c93ba1..37aef0a84f743 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.new.js @@ -13,19 +13,13 @@ import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {Flags} from './ReactFiberFlags'; import * as React from 'react'; -import { - LayoutStatic, - MountLayoutDev, - Update, - Snapshot, -} from './ReactFiberFlags'; +import {LayoutStatic, Update, Snapshot} from './ReactFiberFlags'; import { debugRenderPhaseSideEffectsForStrictMode, disableLegacyContext, enableDebugTracing, enableSchedulingProfiler, warnAboutDeprecatedLifecycles, - enableStrictEffects, enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import ReactStrictModeWarnings from './ReactStrictModeWarnings.new'; @@ -39,12 +33,7 @@ import isArray from 'shared/isArray'; import {REACT_CONTEXT_TYPE, REACT_PROVIDER_TYPE} from 'shared/ReactSymbols'; import {resolveDefaultProps} from './ReactFiberLazyComponent.new'; -import { - DebugTracingMode, - NoMode, - StrictLegacyMode, - StrictEffectsMode, -} from './ReactTypeOfMode'; +import {DebugTracingMode, StrictLegacyMode} from './ReactTypeOfMode'; import { enqueueUpdate, @@ -907,14 +896,7 @@ function mountClassInstance( } if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } } @@ -985,14 +967,7 @@ function resumeMountClassInstance( // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } return false; @@ -1036,28 +1011,14 @@ function resumeMountClassInstance( } } if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } } else { // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.old.js b/packages/react-reconciler/src/ReactFiberClassComponent.old.js index aa709f7ad6cf2..3211717d886f1 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.old.js @@ -13,19 +13,13 @@ import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {Flags} from './ReactFiberFlags'; import * as React from 'react'; -import { - LayoutStatic, - MountLayoutDev, - Update, - Snapshot, -} from './ReactFiberFlags'; +import {LayoutStatic, Update, Snapshot} from './ReactFiberFlags'; import { debugRenderPhaseSideEffectsForStrictMode, disableLegacyContext, enableDebugTracing, enableSchedulingProfiler, warnAboutDeprecatedLifecycles, - enableStrictEffects, enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import ReactStrictModeWarnings from './ReactStrictModeWarnings.old'; @@ -39,12 +33,7 @@ import isArray from 'shared/isArray'; import {REACT_CONTEXT_TYPE, REACT_PROVIDER_TYPE} from 'shared/ReactSymbols'; import {resolveDefaultProps} from './ReactFiberLazyComponent.old'; -import { - DebugTracingMode, - NoMode, - StrictLegacyMode, - StrictEffectsMode, -} from './ReactTypeOfMode'; +import {DebugTracingMode, StrictLegacyMode} from './ReactTypeOfMode'; import { enqueueUpdate, @@ -907,14 +896,7 @@ function mountClassInstance( } if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } } @@ -985,14 +967,7 @@ function resumeMountClassInstance( // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } return false; @@ -1036,28 +1011,14 @@ function resumeMountClassInstance( } } if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } } else { // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { - let fiberFlags: Flags = Update | LayoutStatic; - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDev; - } + const fiberFlags: Flags = Update | LayoutStatic; workInProgress.flags |= fiberFlags; } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 3f7417fc626d8..d2cd313a05ac8 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -30,7 +30,11 @@ import type { import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.new'; import type {RootState} from './ReactFiberRoot.new'; -import type {Transition} from './ReactFiberTracingMarkerComponent.new'; +import type { + Transition, + TracingMarkerInstance, + TransitionAbort, +} from './ReactFiberTracingMarkerComponent.new'; import { enableCreateEventHandleAPI, @@ -40,7 +44,6 @@ import { enableSchedulingProfiler, enableSuspenseCallback, enableScopeAPI, - enableStrictEffects, deletedTreeCleanUpLevel, enableUpdaterTracking, enableCache, @@ -146,8 +149,12 @@ import { addTransitionProgressCallbackToPendingTransition, addTransitionCompleteCallbackToPendingTransition, addMarkerProgressCallbackToPendingTransition, + addMarkerIncompleteCallbackToPendingTransition, addMarkerCompleteCallbackToPendingTransition, setIsRunningInsertionEffect, + getExecutionContext, + CommitContext, + NoContext, } from './ReactFiberWorkLoop.new'; import { NoFlags as NoHookEffect, @@ -177,6 +184,10 @@ import { OffscreenVisible, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; +import { + TransitionRoot, + TransitionTracingMarker, +} from './ReactFiberTracingMarkerComponent.new'; let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null; if (__DEV__) { @@ -196,6 +207,15 @@ let nextEffect: Fiber | null = null; let inProgressLanes: Lanes | null = null; let inProgressRoot: FiberRoot | null = null; +function shouldProfile(current: Fiber): boolean { + return ( + enableProfilerTimer && + enableProfilerCommitHooks && + (current.mode & ProfileMode) !== NoMode && + (getExecutionContext() & CommitContext) !== NoContext + ); +} + export function reportUncaughtErrorInDEV(error: mixed) { // Wrapping each small part of the commit phase into a guarded // callback is a bit too slow (https://github.com/facebook/react/pull/21666). @@ -213,11 +233,7 @@ export function reportUncaughtErrorInDEV(error: mixed) { const callComponentWillUnmountWithTimer = function(current, instance) { instance.props = current.memoizedProps; instance.state = current.memoizedState; - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - current.mode & ProfileMode - ) { + if (shouldProfile(current)) { try { startLayoutEffectTimer(); instance.componentWillUnmount(); @@ -257,11 +273,7 @@ function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) { if (typeof ref === 'function') { let retVal; try { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - current.mode & ProfileMode - ) { + if (shouldProfile(current)) { try { startLayoutEffectTimer(); retVal = ref(null); @@ -637,7 +649,11 @@ export function commitPassiveEffectDurations( finishedRoot: FiberRoot, finishedWork: Fiber, ): void { - if (enableProfilerTimer && enableProfilerCommitHooks) { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + getExecutionContext() & CommitContext + ) { // Only Profilers with work in their subtree will have an Update effect scheduled. if ((finishedWork.flags & Update) !== NoFlags) { switch (finishedWork.tag) { @@ -690,11 +706,7 @@ function commitHookLayoutEffects(finishedWork: Fiber, hookFlags: HookFlags) { // This is done to prevent sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); commitHookEffectListMount(hookFlags, finishedWork); @@ -747,11 +759,7 @@ function commitClassLayoutLifecycles( } } } - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); instance.componentDidMount(); @@ -802,11 +810,7 @@ function commitClassLayoutLifecycles( } } } - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); instance.componentDidUpdate( @@ -888,7 +892,7 @@ function commitHostComponentMount(finishedWork: Fiber) { } function commitProfilerUpdate(finishedWork: Fiber, current: Fiber | null) { - if (enableProfilerTimer) { + if (enableProfilerTimer && getExecutionContext() & CommitContext) { try { const {onCommit, onRender} = finishedWork.memoizedProps; const {effectDuration} = finishedWork.stateNode; @@ -1099,7 +1103,6 @@ function commitLayoutEffectOnFiber( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); } else { @@ -1132,6 +1135,141 @@ function commitLayoutEffectOnFiber( } } +function abortRootTransitions( + root: FiberRoot, + abort: TransitionAbort, + deletedTransitions: Set<Transition>, + deletedOffscreenInstance: OffscreenInstance | null, + isInDeletedTree: boolean, +) { + if (enableTransitionTracing) { + const rootTransitions = root.incompleteTransitions; + deletedTransitions.forEach(transition => { + if (rootTransitions.has(transition)) { + const transitionInstance: TracingMarkerInstance = (rootTransitions.get( + transition, + ): any); + if (transitionInstance.aborts === null) { + transitionInstance.aborts = []; + } + transitionInstance.aborts.push(abort); + + if (deletedOffscreenInstance !== null) { + if ( + transitionInstance.pendingBoundaries !== null && + transitionInstance.pendingBoundaries.has(deletedOffscreenInstance) + ) { + transitionInstance.pendingBoundaries.delete( + deletedOffscreenInstance, + ); + } + } + } + }); + } +} + +function abortTracingMarkerTransitions( + abortedFiber: Fiber, + abort: TransitionAbort, + deletedTransitions: Set<Transition>, + deletedOffscreenInstance: OffscreenInstance | null, + isInDeletedTree: boolean, +) { + if (enableTransitionTracing) { + const markerInstance: TracingMarkerInstance = abortedFiber.stateNode; + const markerTransitions = markerInstance.transitions; + const pendingBoundaries = markerInstance.pendingBoundaries; + if (markerTransitions !== null) { + // TODO: Refactor this code. Is there a way to move this code to + // the deletions phase instead of calculating it here while making sure + // complete is called appropriately? + deletedTransitions.forEach(transition => { + // If one of the transitions on the tracing marker is a transition + // that was in an aborted subtree, we will abort that tracing marker + if ( + abortedFiber !== null && + markerTransitions.has(transition) && + (markerInstance.aborts === null || + !markerInstance.aborts.includes(abort)) + ) { + if (markerInstance.transitions !== null) { + if (markerInstance.aborts === null) { + markerInstance.aborts = [abort]; + addMarkerIncompleteCallbackToPendingTransition( + abortedFiber.memoizedProps.name, + markerInstance.transitions, + markerInstance.aborts, + ); + } else { + markerInstance.aborts.push(abort); + } + + // We only want to call onTransitionProgress when the marker hasn't been + // deleted + if ( + deletedOffscreenInstance !== null && + !isInDeletedTree && + pendingBoundaries !== null && + pendingBoundaries.has(deletedOffscreenInstance) + ) { + pendingBoundaries.delete(deletedOffscreenInstance); + + addMarkerProgressCallbackToPendingTransition( + abortedFiber.memoizedProps.name, + deletedTransitions, + pendingBoundaries, + ); + } + } + } + }); + } + } +} + +function abortParentMarkerTransitionsForDeletedFiber( + abortedFiber: Fiber, + abort: TransitionAbort, + deletedTransitions: Set<Transition>, + deletedOffscreenInstance: OffscreenInstance | null, + isInDeletedTree: boolean, +) { + if (enableTransitionTracing) { + // Find all pending markers that are waiting on child suspense boundaries in the + // aborted subtree and cancels them + let fiber = abortedFiber; + while (fiber !== null) { + switch (fiber.tag) { + case TracingMarkerComponent: + abortTracingMarkerTransitions( + fiber, + abort, + deletedTransitions, + deletedOffscreenInstance, + isInDeletedTree, + ); + break; + case HostRoot: + const root = fiber.stateNode; + abortRootTransitions( + root, + abort, + deletedTransitions, + deletedOffscreenInstance, + isInDeletedTree, + ); + + break; + default: + break; + } + + fiber = fiber.return; + } + } +} + function commitTransitionProgress(offscreenFiber: Fiber) { if (enableTransitionTracing) { // This function adds suspense boundaries to the root @@ -1177,6 +1315,7 @@ function commitTransitionProgress(offscreenFiber: Fiber) { pendingMarkers.forEach(markerInstance => { const pendingBoundaries = markerInstance.pendingBoundaries; const transitions = markerInstance.transitions; + const markerName = markerInstance.name; if ( pendingBoundaries !== null && !pendingBoundaries.has(offscreenInstance) @@ -1185,13 +1324,16 @@ function commitTransitionProgress(offscreenFiber: Fiber) { name, }); if (transitions !== null) { - if (markerInstance.name) { + if ( + markerInstance.tag === TransitionTracingMarker && + markerName !== null + ) { addMarkerProgressCallbackToPendingTransition( - markerInstance.name, + markerName, transitions, pendingBoundaries, ); - } else { + } else if (markerInstance.tag === TransitionRoot) { transitions.forEach(transition => { addTransitionProgressCallbackToPendingTransition( transition, @@ -1211,19 +1353,37 @@ function commitTransitionProgress(offscreenFiber: Fiber) { pendingMarkers.forEach(markerInstance => { const pendingBoundaries = markerInstance.pendingBoundaries; const transitions = markerInstance.transitions; + const markerName = markerInstance.name; if ( pendingBoundaries !== null && pendingBoundaries.has(offscreenInstance) ) { pendingBoundaries.delete(offscreenInstance); if (transitions !== null) { - if (markerInstance.name) { + if ( + markerInstance.tag === TransitionTracingMarker && + markerName !== null + ) { addMarkerProgressCallbackToPendingTransition( - markerInstance.name, + markerName, transitions, pendingBoundaries, ); - } else { + + // If there are no more unresolved suspense boundaries, the interaction + // is considered finished + if (pendingBoundaries.size === 0) { + if (markerInstance.aborts === null) { + addMarkerCompleteCallbackToPendingTransition( + markerName, + transitions, + ); + } + markerInstance.transitions = null; + markerInstance.pendingBoundaries = null; + markerInstance.aborts = null; + } + } else if (markerInstance.tag === TransitionRoot) { transitions.forEach(transition => { addTransitionProgressCallbackToPendingTransition( transition, @@ -1332,11 +1492,7 @@ function commitAttachRef(finishedWork: Fiber) { } if (typeof ref === 'function') { let retVal; - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); retVal = ref(instanceToUse); @@ -1375,11 +1531,7 @@ function commitDetachRef(current: Fiber) { const currentRef = current.ref; if (currentRef !== null) { if (typeof currentRef === 'function') { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - current.mode & ProfileMode - ) { + if (shouldProfile(current)) { try { startLayoutEffectTimer(); currentRef(null); @@ -1741,6 +1893,7 @@ function commitDeletionEffects( 'a bug in React. Please file an issue.', ); } + commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber); hostParent = null; hostParentIsContainer = false; @@ -1905,11 +2058,7 @@ function commitDeletionEffectsOnFiber( markComponentLayoutEffectUnmountStarted(deletedFiber); } - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - deletedFiber.mode & ProfileMode - ) { + if (shouldProfile(deletedFiber)) { startLayoutEffectTimer(); safelyCallDestroy( deletedFiber, @@ -1987,6 +2136,7 @@ function commitDeletionEffectsOnFiber( const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null; + recursivelyTraverseDeletionEffects( finishedRoot, nearestMountedAncestor, @@ -2228,11 +2378,7 @@ function commitMutationEffectsOnFiber( // This prevents sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); commitHookEffectListUnmount( @@ -2618,18 +2764,14 @@ function recursivelyTraverseLayoutEffects( setCurrentDebugFiberInDEV(prevDebugFiber); } -function disappearLayoutEffects(finishedWork: Fiber) { +export function disappearLayoutEffects(finishedWork: Fiber) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case MemoComponent: case SimpleMemoComponent: { // TODO (Offscreen) Check: flags & LayoutStatic - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); commitHookEffectListUnmount( @@ -2700,11 +2842,10 @@ function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) { } } -function reappearLayoutEffects( +export function reappearLayoutEffects( finishedRoot: FiberRoot, current: Fiber | null, finishedWork: Fiber, - committedLanes: Lanes, // This function visits both newly finished work and nodes that were re-used // from a previously committed tree. We cannot check non-static flags if the // node was reused. @@ -2719,7 +2860,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); // TODO: Check flags & LayoutStatic @@ -2730,7 +2870,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); @@ -2772,7 +2911,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); @@ -2792,7 +2930,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); // TODO: Figure out how Profiler updates should work with Offscreen @@ -2805,7 +2942,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); @@ -2825,7 +2961,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); } @@ -2835,7 +2970,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); break; @@ -2846,7 +2980,6 @@ function reappearLayoutEffects( function recursivelyTraverseReappearLayoutEffects( finishedRoot: FiberRoot, parentFiber: Fiber, - committedLanes: Lanes, includeWorkInProgressEffects: boolean, ) { // This function visits both newly finished work and nodes that were re-used @@ -2865,7 +2998,6 @@ function recursivelyTraverseReappearLayoutEffects( finishedRoot, current, child, - committedLanes, childShouldIncludeWorkInProgressEffects, ); child = child.sibling; @@ -2877,11 +3009,7 @@ function commitHookPassiveMountEffects( finishedWork: Fiber, hookFlags: HookFlags, ) { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { startPassiveEffectTimer(); try { commitHookEffectListMount(hookFlags, finishedWork); @@ -2987,6 +3115,12 @@ function commitOffscreenPassiveMountEffects( } commitTransitionProgress(finishedWork); + + // TODO: Refactor this into an if/else branch + if (!isHidden) { + instance.transitions = null; + instance.pendingMarkers = null; + } } } @@ -3017,20 +3151,18 @@ function commitCachePassiveMountEffect( function commitTracingMarkerPassiveMountEffect(finishedWork: Fiber) { // Get the transitions that were initiatized during the render // and add a start transition callback for each of them + // We will only call this on initial mount of the tracing marker + // only if there are no suspense children const instance = finishedWork.stateNode; - if ( - instance.transitions !== null && - (instance.pendingBoundaries === null || - instance.pendingBoundaries.size === 0) - ) { - instance.transitions.forEach(transition => { - addMarkerCompleteCallbackToPendingTransition( - finishedWork.memoizedProps.name, - instance.transitions, - ); - }); + if (instance.transitions !== null && instance.pendingBoundaries === null) { + addMarkerCompleteCallbackToPendingTransition( + finishedWork.memoizedProps.name, + instance.transitions, + ); instance.transitions = null; instance.pendingBoundaries = null; + instance.aborts = null; + instance.name = null; } } @@ -3132,7 +3264,7 @@ function commitPassiveMountOnFiber( if (enableTransitionTracing) { // Get the transitions that were initiatized during the render // and add a start transition callback for each of them - const root = finishedWork.stateNode; + const root: FiberRoot = finishedWork.stateNode; const incompleteTransitions = root.incompleteTransitions; // Initial render if (committedTransitions !== null) { @@ -3146,7 +3278,9 @@ function commitPassiveMountOnFiber( incompleteTransitions.forEach((markerInstance, transition) => { const pendingBoundaries = markerInstance.pendingBoundaries; if (pendingBoundaries === null || pendingBoundaries.size === 0) { - addTransitionCompleteCallbackToPendingTransition(transition); + if (markerInstance.aborts === null) { + addTransitionCompleteCallbackToPendingTransition(transition); + } incompleteTransitions.delete(transition); } }); @@ -3305,7 +3439,7 @@ function recursivelyTraverseReconnectPassiveEffects( setCurrentDebugFiberInDEV(prevDebugFiber); } -function reconnectPassiveEffects( +export function reconnectPassiveEffects( finishedRoot: FiberRoot, finishedWork: Fiber, committedLanes: Lanes, @@ -3519,21 +3653,6 @@ function commitAtomicPassiveEffects( } break; } - case TracingMarkerComponent: { - if (enableTransitionTracing) { - recursivelyTraverseAtomicPassiveEffects( - finishedRoot, - finishedWork, - committedLanes, - committedTransitions, - ); - if (flags & Passive) { - commitTracingMarkerPassiveMountEffect(finishedWork); - } - break; - } - // Intentional fallthrough to next branch - } // eslint-disable-next-line-no-fallthrough default: { recursivelyTraverseAtomicPassiveEffects( @@ -3586,11 +3705,7 @@ function commitHookPassiveUnmountEffects( nearestMountedAncestor, hookFlags: HookFlags, ) { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { startPassiveEffectTimer(); commitHookEffectListUnmount( hookFlags, @@ -3719,7 +3834,7 @@ function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void { setCurrentDebugFiberInDEV(prevDebugFiber); } -function disconnectPassiveEffect(finishedWork: Fiber): void { +export function disconnectPassiveEffect(finishedWork: Fiber): void { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: @@ -3861,6 +3976,43 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( } break; } + case SuspenseComponent: { + if (enableTransitionTracing) { + // We need to mark this fiber's parents as deleted + const offscreenFiber: Fiber = (current.child: any); + const instance: OffscreenInstance = offscreenFiber.stateNode; + const transitions = instance.transitions; + if (transitions !== null) { + const abortReason = { + reason: 'suspense', + name: current.memoizedProps.unstable_name || null, + }; + if ( + current.memoizedState === null || + current.memoizedState.dehydrated === null + ) { + abortParentMarkerTransitionsForDeletedFiber( + offscreenFiber, + abortReason, + transitions, + instance, + true, + ); + + if (nearestMountedAncestor !== null) { + abortParentMarkerTransitionsForDeletedFiber( + nearestMountedAncestor, + abortReason, + transitions, + instance, + false, + ); + } + } + } + } + break; + } case CacheComponent: { if (enableCache) { const cache = current.memoizedState.cache; @@ -3868,115 +4020,38 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( } break; } - } -} - -// TODO: Reuse reappearLayoutEffects traversal here? -function invokeLayoutEffectMountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListMount(HookLayout | HookHasEffect, fiber); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - case ClassComponent: { - const instance = fiber.stateNode; - try { - instance.componentDidMount(); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - } - } -} - -function invokePassiveEffectMountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListMount(HookPassive | HookHasEffect, fiber); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - } - } -} - -function invokeLayoutEffectUnmountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListUnmount( - HookLayout | HookHasEffect, - fiber, - fiber.return, + case TracingMarkerComponent: { + if (enableTransitionTracing) { + // We need to mark this fiber's parents as deleted + const instance: TracingMarkerInstance = current.stateNode; + const transitions = instance.transitions; + if (transitions !== null) { + const abortReason = { + reason: 'marker', + name: current.memoizedProps.name, + }; + abortParentMarkerTransitionsForDeletedFiber( + current, + abortReason, + transitions, + null, + true, ); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - case ClassComponent: { - const instance = fiber.stateNode; - if (typeof instance.componentWillUnmount === 'function') { - safelyCallComponentWillUnmount(fiber, fiber.return, instance); - } - break; - } - } - } -} -function invokePassiveEffectUnmountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListUnmount( - HookPassive | HookHasEffect, - fiber, - fiber.return, - ); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); + if (nearestMountedAncestor !== null) { + abortParentMarkerTransitionsForDeletedFiber( + nearestMountedAncestor, + abortReason, + transitions, + null, + false, + ); + } } } + break; } } } -export { - commitPlacement, - commitAttachRef, - commitDetachRef, - invokeLayoutEffectMountInDEV, - invokeLayoutEffectUnmountInDEV, - invokePassiveEffectMountInDEV, - invokePassiveEffectUnmountInDEV, -}; +export {commitPlacement, commitAttachRef, commitDetachRef}; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index b0e16e8ab2aeb..70a68617763c3 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -30,7 +30,11 @@ import type { import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.old'; import type {RootState} from './ReactFiberRoot.old'; -import type {Transition} from './ReactFiberTracingMarkerComponent.old'; +import type { + Transition, + TracingMarkerInstance, + TransitionAbort, +} from './ReactFiberTracingMarkerComponent.old'; import { enableCreateEventHandleAPI, @@ -40,7 +44,6 @@ import { enableSchedulingProfiler, enableSuspenseCallback, enableScopeAPI, - enableStrictEffects, deletedTreeCleanUpLevel, enableUpdaterTracking, enableCache, @@ -146,8 +149,12 @@ import { addTransitionProgressCallbackToPendingTransition, addTransitionCompleteCallbackToPendingTransition, addMarkerProgressCallbackToPendingTransition, + addMarkerIncompleteCallbackToPendingTransition, addMarkerCompleteCallbackToPendingTransition, setIsRunningInsertionEffect, + getExecutionContext, + CommitContext, + NoContext, } from './ReactFiberWorkLoop.old'; import { NoFlags as NoHookEffect, @@ -177,6 +184,10 @@ import { OffscreenVisible, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; +import { + TransitionRoot, + TransitionTracingMarker, +} from './ReactFiberTracingMarkerComponent.old'; let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null; if (__DEV__) { @@ -196,6 +207,15 @@ let nextEffect: Fiber | null = null; let inProgressLanes: Lanes | null = null; let inProgressRoot: FiberRoot | null = null; +function shouldProfile(current: Fiber): boolean { + return ( + enableProfilerTimer && + enableProfilerCommitHooks && + (current.mode & ProfileMode) !== NoMode && + (getExecutionContext() & CommitContext) !== NoContext + ); +} + export function reportUncaughtErrorInDEV(error: mixed) { // Wrapping each small part of the commit phase into a guarded // callback is a bit too slow (https://github.com/facebook/react/pull/21666). @@ -213,11 +233,7 @@ export function reportUncaughtErrorInDEV(error: mixed) { const callComponentWillUnmountWithTimer = function(current, instance) { instance.props = current.memoizedProps; instance.state = current.memoizedState; - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - current.mode & ProfileMode - ) { + if (shouldProfile(current)) { try { startLayoutEffectTimer(); instance.componentWillUnmount(); @@ -257,11 +273,7 @@ function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) { if (typeof ref === 'function') { let retVal; try { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - current.mode & ProfileMode - ) { + if (shouldProfile(current)) { try { startLayoutEffectTimer(); retVal = ref(null); @@ -637,7 +649,11 @@ export function commitPassiveEffectDurations( finishedRoot: FiberRoot, finishedWork: Fiber, ): void { - if (enableProfilerTimer && enableProfilerCommitHooks) { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + getExecutionContext() & CommitContext + ) { // Only Profilers with work in their subtree will have an Update effect scheduled. if ((finishedWork.flags & Update) !== NoFlags) { switch (finishedWork.tag) { @@ -690,11 +706,7 @@ function commitHookLayoutEffects(finishedWork: Fiber, hookFlags: HookFlags) { // This is done to prevent sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); commitHookEffectListMount(hookFlags, finishedWork); @@ -747,11 +759,7 @@ function commitClassLayoutLifecycles( } } } - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); instance.componentDidMount(); @@ -802,11 +810,7 @@ function commitClassLayoutLifecycles( } } } - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); instance.componentDidUpdate( @@ -888,7 +892,7 @@ function commitHostComponentMount(finishedWork: Fiber) { } function commitProfilerUpdate(finishedWork: Fiber, current: Fiber | null) { - if (enableProfilerTimer) { + if (enableProfilerTimer && getExecutionContext() & CommitContext) { try { const {onCommit, onRender} = finishedWork.memoizedProps; const {effectDuration} = finishedWork.stateNode; @@ -1099,7 +1103,6 @@ function commitLayoutEffectOnFiber( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); } else { @@ -1132,6 +1135,141 @@ function commitLayoutEffectOnFiber( } } +function abortRootTransitions( + root: FiberRoot, + abort: TransitionAbort, + deletedTransitions: Set<Transition>, + deletedOffscreenInstance: OffscreenInstance | null, + isInDeletedTree: boolean, +) { + if (enableTransitionTracing) { + const rootTransitions = root.incompleteTransitions; + deletedTransitions.forEach(transition => { + if (rootTransitions.has(transition)) { + const transitionInstance: TracingMarkerInstance = (rootTransitions.get( + transition, + ): any); + if (transitionInstance.aborts === null) { + transitionInstance.aborts = []; + } + transitionInstance.aborts.push(abort); + + if (deletedOffscreenInstance !== null) { + if ( + transitionInstance.pendingBoundaries !== null && + transitionInstance.pendingBoundaries.has(deletedOffscreenInstance) + ) { + transitionInstance.pendingBoundaries.delete( + deletedOffscreenInstance, + ); + } + } + } + }); + } +} + +function abortTracingMarkerTransitions( + abortedFiber: Fiber, + abort: TransitionAbort, + deletedTransitions: Set<Transition>, + deletedOffscreenInstance: OffscreenInstance | null, + isInDeletedTree: boolean, +) { + if (enableTransitionTracing) { + const markerInstance: TracingMarkerInstance = abortedFiber.stateNode; + const markerTransitions = markerInstance.transitions; + const pendingBoundaries = markerInstance.pendingBoundaries; + if (markerTransitions !== null) { + // TODO: Refactor this code. Is there a way to move this code to + // the deletions phase instead of calculating it here while making sure + // complete is called appropriately? + deletedTransitions.forEach(transition => { + // If one of the transitions on the tracing marker is a transition + // that was in an aborted subtree, we will abort that tracing marker + if ( + abortedFiber !== null && + markerTransitions.has(transition) && + (markerInstance.aborts === null || + !markerInstance.aborts.includes(abort)) + ) { + if (markerInstance.transitions !== null) { + if (markerInstance.aborts === null) { + markerInstance.aborts = [abort]; + addMarkerIncompleteCallbackToPendingTransition( + abortedFiber.memoizedProps.name, + markerInstance.transitions, + markerInstance.aborts, + ); + } else { + markerInstance.aborts.push(abort); + } + + // We only want to call onTransitionProgress when the marker hasn't been + // deleted + if ( + deletedOffscreenInstance !== null && + !isInDeletedTree && + pendingBoundaries !== null && + pendingBoundaries.has(deletedOffscreenInstance) + ) { + pendingBoundaries.delete(deletedOffscreenInstance); + + addMarkerProgressCallbackToPendingTransition( + abortedFiber.memoizedProps.name, + deletedTransitions, + pendingBoundaries, + ); + } + } + } + }); + } + } +} + +function abortParentMarkerTransitionsForDeletedFiber( + abortedFiber: Fiber, + abort: TransitionAbort, + deletedTransitions: Set<Transition>, + deletedOffscreenInstance: OffscreenInstance | null, + isInDeletedTree: boolean, +) { + if (enableTransitionTracing) { + // Find all pending markers that are waiting on child suspense boundaries in the + // aborted subtree and cancels them + let fiber = abortedFiber; + while (fiber !== null) { + switch (fiber.tag) { + case TracingMarkerComponent: + abortTracingMarkerTransitions( + fiber, + abort, + deletedTransitions, + deletedOffscreenInstance, + isInDeletedTree, + ); + break; + case HostRoot: + const root = fiber.stateNode; + abortRootTransitions( + root, + abort, + deletedTransitions, + deletedOffscreenInstance, + isInDeletedTree, + ); + + break; + default: + break; + } + + fiber = fiber.return; + } + } +} + function commitTransitionProgress(offscreenFiber: Fiber) { if (enableTransitionTracing) { // This function adds suspense boundaries to the root @@ -1177,6 +1315,7 @@ function commitTransitionProgress(offscreenFiber: Fiber) { pendingMarkers.forEach(markerInstance => { const pendingBoundaries = markerInstance.pendingBoundaries; const transitions = markerInstance.transitions; + const markerName = markerInstance.name; if ( pendingBoundaries !== null && !pendingBoundaries.has(offscreenInstance) @@ -1185,13 +1324,16 @@ function commitTransitionProgress(offscreenFiber: Fiber) { name, }); if (transitions !== null) { - if (markerInstance.name) { + if ( + markerInstance.tag === TransitionTracingMarker && + markerName !== null + ) { addMarkerProgressCallbackToPendingTransition( - markerInstance.name, + markerName, transitions, pendingBoundaries, ); - } else { + } else if (markerInstance.tag === TransitionRoot) { transitions.forEach(transition => { addTransitionProgressCallbackToPendingTransition( transition, @@ -1211,19 +1353,37 @@ function commitTransitionProgress(offscreenFiber: Fiber) { pendingMarkers.forEach(markerInstance => { const pendingBoundaries = markerInstance.pendingBoundaries; const transitions = markerInstance.transitions; + const markerName = markerInstance.name; if ( pendingBoundaries !== null && pendingBoundaries.has(offscreenInstance) ) { pendingBoundaries.delete(offscreenInstance); if (transitions !== null) { - if (markerInstance.name) { + if ( + markerInstance.tag === TransitionTracingMarker && + markerName !== null + ) { addMarkerProgressCallbackToPendingTransition( - markerInstance.name, + markerName, transitions, pendingBoundaries, ); - } else { + + // If there are no more unresolved suspense boundaries, the interaction + // is considered finished + if (pendingBoundaries.size === 0) { + if (markerInstance.aborts === null) { + addMarkerCompleteCallbackToPendingTransition( + markerName, + transitions, + ); + } + markerInstance.transitions = null; + markerInstance.pendingBoundaries = null; + markerInstance.aborts = null; + } + } else if (markerInstance.tag === TransitionRoot) { transitions.forEach(transition => { addTransitionProgressCallbackToPendingTransition( transition, @@ -1332,11 +1492,7 @@ function commitAttachRef(finishedWork: Fiber) { } if (typeof ref === 'function') { let retVal; - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); retVal = ref(instanceToUse); @@ -1375,11 +1531,7 @@ function commitDetachRef(current: Fiber) { const currentRef = current.ref; if (currentRef !== null) { if (typeof currentRef === 'function') { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - current.mode & ProfileMode - ) { + if (shouldProfile(current)) { try { startLayoutEffectTimer(); currentRef(null); @@ -1741,6 +1893,7 @@ function commitDeletionEffects( 'a bug in React. Please file an issue.', ); } + commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber); hostParent = null; hostParentIsContainer = false; @@ -1905,11 +2058,7 @@ function commitDeletionEffectsOnFiber( markComponentLayoutEffectUnmountStarted(deletedFiber); } - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - deletedFiber.mode & ProfileMode - ) { + if (shouldProfile(deletedFiber)) { startLayoutEffectTimer(); safelyCallDestroy( deletedFiber, @@ -1987,6 +2136,7 @@ function commitDeletionEffectsOnFiber( const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null; + recursivelyTraverseDeletionEffects( finishedRoot, nearestMountedAncestor, @@ -2228,11 +2378,7 @@ function commitMutationEffectsOnFiber( // This prevents sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); commitHookEffectListUnmount( @@ -2618,18 +2764,14 @@ function recursivelyTraverseLayoutEffects( setCurrentDebugFiberInDEV(prevDebugFiber); } -function disappearLayoutEffects(finishedWork: Fiber) { +export function disappearLayoutEffects(finishedWork: Fiber) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case MemoComponent: case SimpleMemoComponent: { // TODO (Offscreen) Check: flags & LayoutStatic - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { try { startLayoutEffectTimer(); commitHookEffectListUnmount( @@ -2700,11 +2842,10 @@ function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) { } } -function reappearLayoutEffects( +export function reappearLayoutEffects( finishedRoot: FiberRoot, current: Fiber | null, finishedWork: Fiber, - committedLanes: Lanes, // This function visits both newly finished work and nodes that were re-used // from a previously committed tree. We cannot check non-static flags if the // node was reused. @@ -2719,7 +2860,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); // TODO: Check flags & LayoutStatic @@ -2730,7 +2870,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); @@ -2772,7 +2911,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); @@ -2792,7 +2930,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); // TODO: Figure out how Profiler updates should work with Offscreen @@ -2805,7 +2942,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); @@ -2825,7 +2961,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); } @@ -2835,7 +2970,6 @@ function reappearLayoutEffects( recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, - committedLanes, includeWorkInProgressEffects, ); break; @@ -2846,7 +2980,6 @@ function reappearLayoutEffects( function recursivelyTraverseReappearLayoutEffects( finishedRoot: FiberRoot, parentFiber: Fiber, - committedLanes: Lanes, includeWorkInProgressEffects: boolean, ) { // This function visits both newly finished work and nodes that were re-used @@ -2865,7 +2998,6 @@ function recursivelyTraverseReappearLayoutEffects( finishedRoot, current, child, - committedLanes, childShouldIncludeWorkInProgressEffects, ); child = child.sibling; @@ -2877,11 +3009,7 @@ function commitHookPassiveMountEffects( finishedWork: Fiber, hookFlags: HookFlags, ) { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { startPassiveEffectTimer(); try { commitHookEffectListMount(hookFlags, finishedWork); @@ -2987,6 +3115,12 @@ function commitOffscreenPassiveMountEffects( } commitTransitionProgress(finishedWork); + + // TODO: Refactor this into an if/else branch + if (!isHidden) { + instance.transitions = null; + instance.pendingMarkers = null; + } } } @@ -3017,20 +3151,18 @@ function commitCachePassiveMountEffect( function commitTracingMarkerPassiveMountEffect(finishedWork: Fiber) { // Get the transitions that were initiatized during the render // and add a start transition callback for each of them + // We will only call this on initial mount of the tracing marker + // only if there are no suspense children const instance = finishedWork.stateNode; - if ( - instance.transitions !== null && - (instance.pendingBoundaries === null || - instance.pendingBoundaries.size === 0) - ) { - instance.transitions.forEach(transition => { - addMarkerCompleteCallbackToPendingTransition( - finishedWork.memoizedProps.name, - instance.transitions, - ); - }); + if (instance.transitions !== null && instance.pendingBoundaries === null) { + addMarkerCompleteCallbackToPendingTransition( + finishedWork.memoizedProps.name, + instance.transitions, + ); instance.transitions = null; instance.pendingBoundaries = null; + instance.aborts = null; + instance.name = null; } } @@ -3132,7 +3264,7 @@ function commitPassiveMountOnFiber( if (enableTransitionTracing) { // Get the transitions that were initiatized during the render // and add a start transition callback for each of them - const root = finishedWork.stateNode; + const root: FiberRoot = finishedWork.stateNode; const incompleteTransitions = root.incompleteTransitions; // Initial render if (committedTransitions !== null) { @@ -3146,7 +3278,9 @@ function commitPassiveMountOnFiber( incompleteTransitions.forEach((markerInstance, transition) => { const pendingBoundaries = markerInstance.pendingBoundaries; if (pendingBoundaries === null || pendingBoundaries.size === 0) { - addTransitionCompleteCallbackToPendingTransition(transition); + if (markerInstance.aborts === null) { + addTransitionCompleteCallbackToPendingTransition(transition); + } incompleteTransitions.delete(transition); } }); @@ -3305,7 +3439,7 @@ function recursivelyTraverseReconnectPassiveEffects( setCurrentDebugFiberInDEV(prevDebugFiber); } -function reconnectPassiveEffects( +export function reconnectPassiveEffects( finishedRoot: FiberRoot, finishedWork: Fiber, committedLanes: Lanes, @@ -3519,21 +3653,6 @@ function commitAtomicPassiveEffects( } break; } - case TracingMarkerComponent: { - if (enableTransitionTracing) { - recursivelyTraverseAtomicPassiveEffects( - finishedRoot, - finishedWork, - committedLanes, - committedTransitions, - ); - if (flags & Passive) { - commitTracingMarkerPassiveMountEffect(finishedWork); - } - break; - } - // Intentional fallthrough to next branch - } // eslint-disable-next-line-no-fallthrough default: { recursivelyTraverseAtomicPassiveEffects( @@ -3586,11 +3705,7 @@ function commitHookPassiveUnmountEffects( nearestMountedAncestor, hookFlags: HookFlags, ) { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - finishedWork.mode & ProfileMode - ) { + if (shouldProfile(finishedWork)) { startPassiveEffectTimer(); commitHookEffectListUnmount( hookFlags, @@ -3719,7 +3834,7 @@ function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void { setCurrentDebugFiberInDEV(prevDebugFiber); } -function disconnectPassiveEffect(finishedWork: Fiber): void { +export function disconnectPassiveEffect(finishedWork: Fiber): void { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: @@ -3861,6 +3976,43 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( } break; } + case SuspenseComponent: { + if (enableTransitionTracing) { + // We need to mark this fiber's parents as deleted + const offscreenFiber: Fiber = (current.child: any); + const instance: OffscreenInstance = offscreenFiber.stateNode; + const transitions = instance.transitions; + if (transitions !== null) { + const abortReason = { + reason: 'suspense', + name: current.memoizedProps.unstable_name || null, + }; + if ( + current.memoizedState === null || + current.memoizedState.dehydrated === null + ) { + abortParentMarkerTransitionsForDeletedFiber( + offscreenFiber, + abortReason, + transitions, + instance, + true, + ); + + if (nearestMountedAncestor !== null) { + abortParentMarkerTransitionsForDeletedFiber( + nearestMountedAncestor, + abortReason, + transitions, + instance, + false, + ); + } + } + } + } + break; + } case CacheComponent: { if (enableCache) { const cache = current.memoizedState.cache; @@ -3868,115 +4020,38 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( } break; } - } -} - -// TODO: Reuse reappearLayoutEffects traversal here? -function invokeLayoutEffectMountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListMount(HookLayout | HookHasEffect, fiber); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - case ClassComponent: { - const instance = fiber.stateNode; - try { - instance.componentDidMount(); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - } - } -} - -function invokePassiveEffectMountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListMount(HookPassive | HookHasEffect, fiber); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - } - } -} - -function invokeLayoutEffectUnmountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListUnmount( - HookLayout | HookHasEffect, - fiber, - fiber.return, + case TracingMarkerComponent: { + if (enableTransitionTracing) { + // We need to mark this fiber's parents as deleted + const instance: TracingMarkerInstance = current.stateNode; + const transitions = instance.transitions; + if (transitions !== null) { + const abortReason = { + reason: 'marker', + name: current.memoizedProps.name, + }; + abortParentMarkerTransitionsForDeletedFiber( + current, + abortReason, + transitions, + null, + true, ); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); - } - break; - } - case ClassComponent: { - const instance = fiber.stateNode; - if (typeof instance.componentWillUnmount === 'function') { - safelyCallComponentWillUnmount(fiber, fiber.return, instance); - } - break; - } - } - } -} -function invokePassiveEffectUnmountInDEV(fiber: Fiber): void { - if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - switch (fiber.tag) { - case FunctionComponent: - case ForwardRef: - case SimpleMemoComponent: { - try { - commitHookEffectListUnmount( - HookPassive | HookHasEffect, - fiber, - fiber.return, - ); - } catch (error) { - captureCommitPhaseError(fiber, fiber.return, error); + if (nearestMountedAncestor !== null) { + abortParentMarkerTransitionsForDeletedFiber( + nearestMountedAncestor, + abortReason, + transitions, + null, + false, + ); + } } } + break; } } } -export { - commitPlacement, - commitAttachRef, - commitDetachRef, - invokeLayoutEffectMountInDEV, - invokeLayoutEffectUnmountInDEV, - invokePassiveEffectMountInDEV, - invokePassiveEffectUnmountInDEV, -}; +export {commitPlacement, commitAttachRef, commitDetachRef}; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 7a8843941eee5..135e9290f549f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -1589,16 +1589,6 @@ function completeWork( popMarkerInstance(workInProgress); } bubbleProperties(workInProgress); - - if ( - current === null || - (workInProgress.subtreeFlags & Visibility) !== NoFlags - ) { - // If any of our suspense children toggle visibility, this means that - // the pending boundaries array needs to be updated, which we only - // do in the passive phase. - workInProgress.flags |= Passive; - } } return null; } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 1ff1a5173ec10..3fd1bffbc3a99 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -1589,16 +1589,6 @@ function completeWork( popMarkerInstance(workInProgress); } bubbleProperties(workInProgress); - - if ( - current === null || - (workInProgress.subtreeFlags & Visibility) !== NoFlags - ) { - // If any of our suspense children toggle visibility, this means that - // the pending boundaries array needs to be updated, which we only - // do in the passive phase. - workInProgress.flags |= Passive; - } } return null; } diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 0f755d865f420..08c124b5cf6e2 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,52 +12,49 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b0000000000000000000000000; -export const PerformedWork = /* */ 0b0000000000000000000000001; +export const NoFlags = /* */ 0b000000000000000000000000; +export const PerformedWork = /* */ 0b000000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b0000000000000000000000010; -export const Update = /* */ 0b0000000000000000000000100; -export const ChildDeletion = /* */ 0b0000000000000000000001000; -export const ContentReset = /* */ 0b0000000000000000000010000; -export const Callback = /* */ 0b0000000000000000000100000; -export const DidCapture = /* */ 0b0000000000000000001000000; -export const ForceClientRender = /* */ 0b0000000000000000010000000; -export const Ref = /* */ 0b0000000000000000100000000; -export const Snapshot = /* */ 0b0000000000000001000000000; -export const Passive = /* */ 0b0000000000000010000000000; -export const Hydrating = /* */ 0b0000000000000100000000000; -export const Visibility = /* */ 0b0000000000001000000000000; -export const StoreConsistency = /* */ 0b0000000000010000000000000; +export const Placement = /* */ 0b000000000000000000000010; +export const Update = /* */ 0b000000000000000000000100; +export const ChildDeletion = /* */ 0b000000000000000000001000; +export const ContentReset = /* */ 0b000000000000000000010000; +export const Callback = /* */ 0b000000000000000000100000; +export const DidCapture = /* */ 0b000000000000000001000000; +export const ForceClientRender = /* */ 0b000000000000000010000000; +export const Ref = /* */ 0b000000000000000100000000; +export const Snapshot = /* */ 0b000000000000001000000000; +export const Passive = /* */ 0b000000000000010000000000; +export const Hydrating = /* */ 0b000000000000100000000000; +export const Visibility = /* */ 0b000000000001000000000000; +export const StoreConsistency = /* */ 0b000000000010000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot | StoreConsistency; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b0000000000011111111111111; +export const HostEffectMask = /* */ 0b000000000011111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b0000000000100000000000000; -export const ShouldCapture = /* */ 0b0000000001000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b0000000010000000000000000; -export const DidPropagateContext = /* */ 0b0000000100000000000000000; -export const NeedsPropagation = /* */ 0b0000001000000000000000000; -export const Forked = /* */ 0b0000010000000000000000000; +export const Incomplete = /* */ 0b000000000100000000000000; +export const ShouldCapture = /* */ 0b000000001000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b000000010000000000000000; +export const DidPropagateContext = /* */ 0b000000100000000000000000; +export const NeedsPropagation = /* */ 0b000001000000000000000000; +export const Forked = /* */ 0b000010000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const RefStatic = /* */ 0b0000100000000000000000000; -export const LayoutStatic = /* */ 0b0001000000000000000000000; -export const PassiveStatic = /* */ 0b0010000000000000000000000; +export const RefStatic = /* */ 0b000100000000000000000000; +export const LayoutStatic = /* */ 0b001000000000000000000000; +export const PassiveStatic = /* */ 0b010000000000000000000000; -// These flags allow us to traverse to fibers that have effects on mount -// without traversing the entire tree after every commit for -// double invoking -export const MountLayoutDev = /* */ 0b0100000000000000000000000; -export const MountPassiveDev = /* */ 0b1000000000000000000000000; +// Flag used to identify newly inserted fibers. It isn't reset after commit unlike `Placement`. +export const PlacementDEV = /* */ 0b100000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 4cc4dda6fa611..2c06f94d58119 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -13,6 +13,8 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; @@ -28,18 +30,14 @@ import { enableNewReconciler, enableCache, enableUseRefAccessWarning, - enableStrictEffects, enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, + enableUseHook, + enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; -import { - NoMode, - ConcurrentMode, - DebugTracingMode, - StrictEffectsMode, -} from './ReactTypeOfMode'; +import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode'; import { NoLane, SyncLane, @@ -67,8 +65,6 @@ import {readContext, checkIfContextChanged} from './ReactFiberNewContext.new'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { LayoutStatic as LayoutStaticEffect, - MountLayoutDev as MountLayoutDevEffect, - MountPassiveDev as MountPassiveDevEffect, Passive as PassiveEffect, PassiveStatic as PassiveStaticEffect, StaticMask as StaticMaskEffect, @@ -119,6 +115,10 @@ import { } from './ReactFiberConcurrentUpdates.new'; import {getTreeId} from './ReactFiberTreeContext.new'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -204,6 +204,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -402,6 +405,7 @@ export function renderWithHooks<Props, SecondArg>( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -440,6 +444,7 @@ export function renderWithHooks<Props, SecondArg>( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -523,6 +528,7 @@ export function renderWithHooks<Props, SecondArg>( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -569,22 +575,7 @@ export function bailoutHooks( lanes: Lanes, ) { workInProgress.updateQueue = current.updateQueue; - // TODO: Don't need to reset the flags here, because they're reset in the - // complete phase (bubbleProperties). - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - workInProgress.flags &= ~( - MountPassiveDevEffect | - MountLayoutDevEffect | - PassiveEffect | - UpdateEffect - ); - } else { - workInProgress.flags &= ~(PassiveEffect | UpdateEffect); - } + workInProgress.flags &= ~(PassiveEffect | UpdateEffect); current.lanes = removeLanes(current.lanes, lanes); } @@ -630,6 +621,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -721,6 +713,77 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { }; } +function use<T>(usable: Usable<T>): T { + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable<T> = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); +} + +function useMemoCache(size: number): Array<any> { + throw new Error('Not implemented.'); +} + function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S { // $FlowFixMe: Flow doesn't like mixed types return typeof action === 'function' ? action(state) : action; @@ -1702,25 +1765,12 @@ function mountEffect( create: () => (() => void) | void, deps: Array<mixed> | void | null, ): void { - if ( - __DEV__ && - enableStrictEffects && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode - ) { - return mountEffectImpl( - MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect, - HookPassive, - create, - deps, - ); - } else { - return mountEffectImpl( - PassiveEffect | PassiveStaticEffect, - HookPassive, - create, - deps, - ); - } + return mountEffectImpl( + PassiveEffect | PassiveStaticEffect, + HookPassive, + create, + deps, + ); } function updateEffect( @@ -1748,14 +1798,7 @@ function mountLayoutEffect( create: () => (() => void) | void, deps: Array<mixed> | void | null, ): void { - let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; - if ( - __DEV__ && - enableStrictEffects && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDevEffect; - } + const fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; return mountEffectImpl(fiberFlags, HookLayout, create, deps); } @@ -1815,14 +1858,7 @@ function mountImperativeHandle<T>( const effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null; - let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; - if ( - __DEV__ && - enableStrictEffects && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDevEffect; - } + const fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; return mountEffectImpl( fiberFlags, HookLayout, @@ -2416,6 +2452,12 @@ if (enableCache) { (ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType; (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } +if (enableUseHook) { + (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; +} +if (enableUseMemoCacheHook) { + (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -2444,6 +2486,12 @@ if (enableCache) { (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } +if (enableUseHook) { + (HooksDispatcherOnMount: Dispatcher).use = use; +} +if (enableUseMemoCacheHook) { + (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -2471,6 +2519,12 @@ if (enableCache) { (HooksDispatcherOnUpdate: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnUpdate: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseMemoCacheHook) { + (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; +} +if (enableUseHook) { + (HooksDispatcherOnUpdate: Dispatcher).use = use; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2499,6 +2553,12 @@ if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseHook) { + (HooksDispatcherOnRerender: Dispatcher).use = use; +} +if (enableUseMemoCacheHook) { + (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -2674,6 +2734,12 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -2816,6 +2882,12 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; + } HooksDispatcherOnUpdateInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -2958,6 +3030,12 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; + } HooksDispatcherOnRerenderInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3101,6 +3179,12 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3260,6 +3344,22 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function<T>( + usable: Usable<T>, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } + if (enableUseMemoCacheHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function( + size: number, + ): Array<any> { + warnInvalidHookAccess(); + return useMemoCache(size); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3419,6 +3519,22 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function<T>( + usable: Usable<T>, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } + if (enableUseMemoCacheHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function( + size: number, + ): Array<any> { + warnInvalidHookAccess(); + return useMemoCache(size); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3579,4 +3695,20 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function<T>( + usable: Usable<T>, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } + if (enableUseMemoCacheHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function( + size: number, + ): Array<any> { + warnInvalidHookAccess(); + return useMemoCache(size); + }; + } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index c3cb1ed820169..e258a9f824e20 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -13,6 +13,8 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; @@ -28,18 +30,14 @@ import { enableNewReconciler, enableCache, enableUseRefAccessWarning, - enableStrictEffects, enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, + enableUseHook, + enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; -import { - NoMode, - ConcurrentMode, - DebugTracingMode, - StrictEffectsMode, -} from './ReactTypeOfMode'; +import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode'; import { NoLane, SyncLane, @@ -67,8 +65,6 @@ import {readContext, checkIfContextChanged} from './ReactFiberNewContext.old'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { LayoutStatic as LayoutStaticEffect, - MountLayoutDev as MountLayoutDevEffect, - MountPassiveDev as MountPassiveDevEffect, Passive as PassiveEffect, PassiveStatic as PassiveStaticEffect, StaticMask as StaticMaskEffect, @@ -119,6 +115,10 @@ import { } from './ReactFiberConcurrentUpdates.old'; import {getTreeId} from './ReactFiberTreeContext.old'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -204,6 +204,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -402,6 +405,7 @@ export function renderWithHooks<Props, SecondArg>( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -440,6 +444,7 @@ export function renderWithHooks<Props, SecondArg>( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -523,6 +528,7 @@ export function renderWithHooks<Props, SecondArg>( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -569,22 +575,7 @@ export function bailoutHooks( lanes: Lanes, ) { workInProgress.updateQueue = current.updateQueue; - // TODO: Don't need to reset the flags here, because they're reset in the - // complete phase (bubbleProperties). - if ( - __DEV__ && - enableStrictEffects && - (workInProgress.mode & StrictEffectsMode) !== NoMode - ) { - workInProgress.flags &= ~( - MountPassiveDevEffect | - MountLayoutDevEffect | - PassiveEffect | - UpdateEffect - ); - } else { - workInProgress.flags &= ~(PassiveEffect | UpdateEffect); - } + workInProgress.flags &= ~(PassiveEffect | UpdateEffect); current.lanes = removeLanes(current.lanes, lanes); } @@ -630,6 +621,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -721,6 +713,77 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { }; } +function use<T>(usable: Usable<T>): T { + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable<T> = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); +} + +function useMemoCache(size: number): Array<any> { + throw new Error('Not implemented.'); +} + function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S { // $FlowFixMe: Flow doesn't like mixed types return typeof action === 'function' ? action(state) : action; @@ -1702,25 +1765,12 @@ function mountEffect( create: () => (() => void) | void, deps: Array<mixed> | void | null, ): void { - if ( - __DEV__ && - enableStrictEffects && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode - ) { - return mountEffectImpl( - MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect, - HookPassive, - create, - deps, - ); - } else { - return mountEffectImpl( - PassiveEffect | PassiveStaticEffect, - HookPassive, - create, - deps, - ); - } + return mountEffectImpl( + PassiveEffect | PassiveStaticEffect, + HookPassive, + create, + deps, + ); } function updateEffect( @@ -1748,14 +1798,7 @@ function mountLayoutEffect( create: () => (() => void) | void, deps: Array<mixed> | void | null, ): void { - let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; - if ( - __DEV__ && - enableStrictEffects && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDevEffect; - } + const fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; return mountEffectImpl(fiberFlags, HookLayout, create, deps); } @@ -1815,14 +1858,7 @@ function mountImperativeHandle<T>( const effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null; - let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; - if ( - __DEV__ && - enableStrictEffects && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode - ) { - fiberFlags |= MountLayoutDevEffect; - } + const fiberFlags: Flags = UpdateEffect | LayoutStaticEffect; return mountEffectImpl( fiberFlags, HookLayout, @@ -2416,6 +2452,12 @@ if (enableCache) { (ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType; (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } +if (enableUseHook) { + (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; +} +if (enableUseMemoCacheHook) { + (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -2444,6 +2486,12 @@ if (enableCache) { (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } +if (enableUseHook) { + (HooksDispatcherOnMount: Dispatcher).use = use; +} +if (enableUseMemoCacheHook) { + (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -2471,6 +2519,12 @@ if (enableCache) { (HooksDispatcherOnUpdate: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnUpdate: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseMemoCacheHook) { + (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; +} +if (enableUseHook) { + (HooksDispatcherOnUpdate: Dispatcher).use = use; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2499,6 +2553,12 @@ if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseHook) { + (HooksDispatcherOnRerender: Dispatcher).use = use; +} +if (enableUseMemoCacheHook) { + (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -2674,6 +2734,12 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -2816,6 +2882,12 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; + } HooksDispatcherOnUpdateInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -2958,6 +3030,12 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; + } HooksDispatcherOnRerenderInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3101,6 +3179,12 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; + } + if (enableUseMemoCacheHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3260,6 +3344,22 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function<T>( + usable: Usable<T>, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } + if (enableUseMemoCacheHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function( + size: number, + ): Array<any> { + warnInvalidHookAccess(); + return useMemoCache(size); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3419,6 +3519,22 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function<T>( + usable: Usable<T>, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } + if (enableUseMemoCacheHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function( + size: number, + ): Array<any> { + warnInvalidHookAccess(); + return useMemoCache(size); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext<T>(context: ReactContext<T>): T { @@ -3579,4 +3695,20 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function<T>( + usable: Usable<T>, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } + if (enableUseMemoCacheHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function( + size: number, + ): Array<any> { + warnInvalidHookAccess(); + return useMemoCache(size); + }; + } } diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index f61c0eb7789e7..9eba0aad9dc21 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -24,10 +24,12 @@ export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; export const canHydrateSuspenseInstance = shim; +export const isHydratableResource = shim; export const isSuspenseInstancePending = shim; export const isSuspenseInstanceFallback = shim; export const getSuspenseInstanceFallbackErrorDetails = shim; export const registerSuspenseInstanceRetry = shim; +export const getMatchingResourceInstance = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const getFirstHydratableChildWithinContainer = shim; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index fd3f9b18a4921..23010daad4970 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -34,6 +34,7 @@ import { NoFlags, DidCapture, } from './ReactFiberFlags'; +import {enableFloat} from 'shared/ReactFeatureFlags'; import { createFiberFromHostInstanceForDeletion, @@ -45,7 +46,9 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, + isHydratableResource, getNextHydratableSibling, + getMatchingResourceInstance, getFirstHydratableChild, getFirstHydratableChildWithinContainer, getFirstHydratableChildWithinSuspenseInstance, @@ -75,6 +78,7 @@ import { restoreSuspendedTreeContext, } from './ReactFiberTreeContext.new'; import {queueRecoverableErrors} from './ReactFiberWorkLoop.new'; +import {getRootHostContainer} from './ReactFiberHostContext.new'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -404,6 +408,19 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } + if (enableFloat) { + if ( + fiber.tag === HostComponent && + isHydratableResource(fiber.type, fiber.pendingProps) + ) { + fiber.stateNode = getMatchingResourceInstance( + fiber.type, + fiber.pendingProps, + getRootHostContainer(), + ); + return; + } + } let nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { @@ -596,6 +613,30 @@ function popHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; } + if ( + enableFloat && + isHydrating && + isHydratableResource(fiber.type, fiber.memoizedProps) + ) { + if (fiber.stateNode === null) { + if (__DEV__) { + const rel = fiber.memoizedProps.rel + ? `rel="${fiber.memoizedProps.rel}" ` + : ''; + const href = fiber.memoizedProps.href + ? `href="${fiber.memoizedProps.href}"` + : ''; + console.error( + 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.', + fiber.type, + rel, + href, + ); + } + throwOnHydrationMismatch(fiber); + } + return true; + } if (fiber !== hydrationParentFiber) { // We're deeper than the current hydration context, inside an inserted // tree. diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 9ac9e4dc666b2..28371e823e5c8 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -34,6 +34,7 @@ import { NoFlags, DidCapture, } from './ReactFiberFlags'; +import {enableFloat} from 'shared/ReactFeatureFlags'; import { createFiberFromHostInstanceForDeletion, @@ -45,7 +46,9 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, + isHydratableResource, getNextHydratableSibling, + getMatchingResourceInstance, getFirstHydratableChild, getFirstHydratableChildWithinContainer, getFirstHydratableChildWithinSuspenseInstance, @@ -75,6 +78,7 @@ import { restoreSuspendedTreeContext, } from './ReactFiberTreeContext.old'; import {queueRecoverableErrors} from './ReactFiberWorkLoop.old'; +import {getRootHostContainer} from './ReactFiberHostContext.old'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -404,6 +408,19 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } + if (enableFloat) { + if ( + fiber.tag === HostComponent && + isHydratableResource(fiber.type, fiber.pendingProps) + ) { + fiber.stateNode = getMatchingResourceInstance( + fiber.type, + fiber.pendingProps, + getRootHostContainer(), + ); + return; + } + } let nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { @@ -596,6 +613,30 @@ function popHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; } + if ( + enableFloat && + isHydrating && + isHydratableResource(fiber.type, fiber.memoizedProps) + ) { + if (fiber.stateNode === null) { + if (__DEV__) { + const rel = fiber.memoizedProps.rel + ? `rel="${fiber.memoizedProps.rel}" ` + : ''; + const href = fiber.memoizedProps.href + ? `href="${fiber.memoizedProps.href}"` + : ''; + console.error( + 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.', + fiber.type, + rel, + href, + ); + } + throwOnHydrationMismatch(fiber); + } + return true; + } if (fiber !== hydrationParentFiber) { // We're deeper than the current hydration context, inside an inserted // tree. diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index c3f8f5329ac79..4aebeba7a205e 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js index b3f31ec0ceac7..5861e9d3d3252 100644 --- a/packages/react-reconciler/src/ReactFiberLane.old.js +++ b/packages/react-reconciler/src/ReactFiberLane.old.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index f171ca0de3943..892fe78ac1b1e 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 9b37cee41edab..f7e16f0bbdcc8 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 4a75d3076ab6c..d4e69b7c66940 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.new'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.new'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.new'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue<mixed>, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; @@ -357,7 +315,7 @@ function throwException( sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, -) { +): void { // The source fiber did not complete. sourceFiber.flags |= Incomplete; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index d34c32770e881..cdc7d3c2a79e4 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.old'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.old'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.old'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue<mixed>, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; @@ -357,7 +315,7 @@ function throwException( sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, -) { +): void { // The source fiber did not complete. sourceFiber.flags |= Incomplete; diff --git a/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js b/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js index fea28213ff82a..1be1e7f930994 100644 --- a/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js @@ -7,7 +7,11 @@ * @flow */ -import type {TransitionTracingCallbacks, Fiber} from './ReactInternalTypes'; +import type { + TransitionTracingCallbacks, + Fiber, + FiberRoot, +} from './ReactInternalTypes'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; import type {StackCursor} from './ReactFiberStack.new'; @@ -21,7 +25,14 @@ export type PendingTransitionCallbacks = { transitionStart: Array<Transition> | null, transitionProgress: Map<Transition, PendingBoundaries> | null, transitionComplete: Array<Transition> | null, - markerProgress: Map<string, TracingMarkerInstance> | null, + markerProgress: Map< + string, + {pendingBoundaries: PendingBoundaries, transitions: Set<Transition>}, + > | null, + markerIncomplete: Map< + string, + {aborts: Array<TransitionAbort>, transitions: Set<Transition>}, + > | null, markerComplete: Map<string, Set<Transition>> | null, }; @@ -36,12 +47,24 @@ export type BatchConfigTransition = { _updatedFibers?: Set<Fiber>, }; +// TODO: Is there a way to not include the tag or name here? export type TracingMarkerInstance = {| - pendingBoundaries: PendingBoundaries | null, + tag?: TracingMarkerTag, transitions: Set<Transition> | null, - name?: string, + pendingBoundaries: PendingBoundaries | null, + aborts: Array<TransitionAbort> | null, + name: string | null, +|}; + +export type TransitionAbort = {| + reason: 'error' | 'unknown' | 'marker' | 'suspense', + name?: string | null, |}; +export const TransitionRoot = 0; +export const TransitionTracingMarker = 1; +export type TracingMarkerTag = 0 | 1; + export type PendingBoundaries = Map<OffscreenInstance, SuspenseInfo>; export function processTransitionCallbacks( @@ -64,6 +87,7 @@ export function processTransitionCallbacks( if (onMarkerProgress != null && markerProgress !== null) { markerProgress.forEach((markerInstance, markerName) => { if (markerInstance.transitions !== null) { + // TODO: Clone the suspense object so users can't modify it const pending = markerInstance.pendingBoundaries !== null ? Array.from(markerInstance.pendingBoundaries.values()) @@ -96,6 +120,48 @@ export function processTransitionCallbacks( }); } + const markerIncomplete = pendingTransitions.markerIncomplete; + const onMarkerIncomplete = callbacks.onMarkerIncomplete; + if (onMarkerIncomplete != null && markerIncomplete !== null) { + markerIncomplete.forEach(({transitions, aborts}, markerName) => { + transitions.forEach(transition => { + const filteredAborts = []; + aborts.forEach(abort => { + switch (abort.reason) { + case 'marker': { + filteredAborts.push({ + type: 'marker', + name: abort.name, + endTime, + }); + break; + } + case 'suspense': { + filteredAborts.push({ + type: 'suspense', + name: abort.name, + endTime, + }); + break; + } + default: { + break; + } + } + }); + + if (filteredAborts.length > 0) { + onMarkerIncomplete( + transition.name, + markerName, + transition.startTime, + filteredAborts, + ); + } + }); + }); + } + const transitionProgress = pendingTransitions.transitionProgress; const onTransitionProgress = callbacks.onTransitionProgress; if (onTransitionProgress != null && transitionProgress !== null) { @@ -140,14 +206,17 @@ export function pushRootMarkerInstance(workInProgress: Fiber): void { // transitions map. Each entry in this map functions like a tracing // marker does, so we can push it onto the marker instance stack const transitions = getWorkInProgressTransitions(); - const root = workInProgress.stateNode; + const root: FiberRoot = workInProgress.stateNode; if (transitions !== null) { transitions.forEach(transition => { if (!root.incompleteTransitions.has(transition)) { const markerInstance: TracingMarkerInstance = { + tag: TransitionRoot, transitions: new Set([transition]), pendingBoundaries: null, + aborts: null, + name: null, }; root.incompleteTransitions.set(transition, markerInstance); } diff --git a/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.old.js b/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.old.js index ddd8289d8a4bc..e88930867851a 100644 --- a/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.old.js @@ -7,7 +7,11 @@ * @flow */ -import type {TransitionTracingCallbacks, Fiber} from './ReactInternalTypes'; +import type { + TransitionTracingCallbacks, + Fiber, + FiberRoot, +} from './ReactInternalTypes'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; import type {StackCursor} from './ReactFiberStack.old'; @@ -21,7 +25,14 @@ export type PendingTransitionCallbacks = { transitionStart: Array<Transition> | null, transitionProgress: Map<Transition, PendingBoundaries> | null, transitionComplete: Array<Transition> | null, - markerProgress: Map<string, TracingMarkerInstance> | null, + markerProgress: Map< + string, + {pendingBoundaries: PendingBoundaries, transitions: Set<Transition>}, + > | null, + markerIncomplete: Map< + string, + {aborts: Array<TransitionAbort>, transitions: Set<Transition>}, + > | null, markerComplete: Map<string, Set<Transition>> | null, }; @@ -36,12 +47,24 @@ export type BatchConfigTransition = { _updatedFibers?: Set<Fiber>, }; +// TODO: Is there a way to not include the tag or name here? export type TracingMarkerInstance = {| - pendingBoundaries: PendingBoundaries | null, + tag?: TracingMarkerTag, transitions: Set<Transition> | null, - name?: string, + pendingBoundaries: PendingBoundaries | null, + aborts: Array<TransitionAbort> | null, + name: string | null, +|}; + +export type TransitionAbort = {| + reason: 'error' | 'unknown' | 'marker' | 'suspense', + name?: string | null, |}; +export const TransitionRoot = 0; +export const TransitionTracingMarker = 1; +export type TracingMarkerTag = 0 | 1; + export type PendingBoundaries = Map<OffscreenInstance, SuspenseInfo>; export function processTransitionCallbacks( @@ -64,6 +87,7 @@ export function processTransitionCallbacks( if (onMarkerProgress != null && markerProgress !== null) { markerProgress.forEach((markerInstance, markerName) => { if (markerInstance.transitions !== null) { + // TODO: Clone the suspense object so users can't modify it const pending = markerInstance.pendingBoundaries !== null ? Array.from(markerInstance.pendingBoundaries.values()) @@ -96,6 +120,48 @@ export function processTransitionCallbacks( }); } + const markerIncomplete = pendingTransitions.markerIncomplete; + const onMarkerIncomplete = callbacks.onMarkerIncomplete; + if (onMarkerIncomplete != null && markerIncomplete !== null) { + markerIncomplete.forEach(({transitions, aborts}, markerName) => { + transitions.forEach(transition => { + const filteredAborts = []; + aborts.forEach(abort => { + switch (abort.reason) { + case 'marker': { + filteredAborts.push({ + type: 'marker', + name: abort.name, + endTime, + }); + break; + } + case 'suspense': { + filteredAborts.push({ + type: 'suspense', + name: abort.name, + endTime, + }); + break; + } + default: { + break; + } + } + }); + + if (filteredAborts.length > 0) { + onMarkerIncomplete( + transition.name, + markerName, + transition.startTime, + filteredAborts, + ); + } + }); + }); + } + const transitionProgress = pendingTransitions.transitionProgress; const onTransitionProgress = callbacks.onTransitionProgress; if (onTransitionProgress != null && transitionProgress !== null) { @@ -140,14 +206,17 @@ export function pushRootMarkerInstance(workInProgress: Fiber): void { // transitions map. Each entry in this map functions like a tracing // marker does, so we can push it onto the marker instance stack const transitions = getWorkInProgressTransitions(); - const root = workInProgress.stateNode; + const root: FiberRoot = workInProgress.stateNode; if (transitions !== null) { transitions.forEach(transition => { if (!root.incompleteTransitions.has(transition)) { const markerInstance: TracingMarkerInstance = { + tag: TransitionRoot, transitions: new Set([transition]), pendingBoundaries: null, + aborts: null, + name: null, }; root.incompleteTransitions.set(transition, markerInstance); } diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js new file mode 100644 index 0000000000000..83bfad32c5cf1 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -0,0 +1,136 @@ +/** + * 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 { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; + +let suspendedThenable: Thenable<mixed> | null = null; +let adHocSuspendCount: number = 0; + +let usedThenables: Array<Thenable<any> | void> | null = null; +let lastUsedThenable: Thenable<any> | null = null; + +const MAX_AD_HOC_SUSPEND_COUNT = 50; + +export function isTrackingSuspendedThenable() { + return suspendedThenable !== null; +} + +export function suspendedThenableDidResolve() { + if (suspendedThenable !== null) { + const status = suspendedThenable.status; + return status === 'fulfilled' || status === 'rejected'; + } + return false; +} + +export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable<mixed> = (wakeable: any); + + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } + suspendedThenable = thenable; + + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + suspendedThenable = null; + break; + default: { + const pendingThenable: PendingThenable<mixed> = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable<mixed> = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } + } +} + +export function resetWakeableStateAfterEachAttempt() { + suspendedThenable = null; + adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; +} + +export function throwIfInfinitePingLoopDetected() { + if (adHocSuspendCount > MAX_AD_HOC_SUSPEND_COUNT) { + // TODO: Guard against an infinite loop by throwing an error if the same + // component suspends too many times in a row. This should be thrown from + // the render phase so that it gets the component stack. + } +} + +export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex<T>( + index: number, +): Thenable<T> | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js new file mode 100644 index 0000000000000..83bfad32c5cf1 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -0,0 +1,136 @@ +/** + * 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 { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; + +let suspendedThenable: Thenable<mixed> | null = null; +let adHocSuspendCount: number = 0; + +let usedThenables: Array<Thenable<any> | void> | null = null; +let lastUsedThenable: Thenable<any> | null = null; + +const MAX_AD_HOC_SUSPEND_COUNT = 50; + +export function isTrackingSuspendedThenable() { + return suspendedThenable !== null; +} + +export function suspendedThenableDidResolve() { + if (suspendedThenable !== null) { + const status = suspendedThenable.status; + return status === 'fulfilled' || status === 'rejected'; + } + return false; +} + +export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable<mixed> = (wakeable: any); + + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } + suspendedThenable = thenable; + + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + suspendedThenable = null; + break; + default: { + const pendingThenable: PendingThenable<mixed> = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable<mixed> = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } + } +} + +export function resetWakeableStateAfterEachAttempt() { + suspendedThenable = null; + adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; +} + +export function throwIfInfinitePingLoopDetected() { + if (adHocSuspendCount > MAX_AD_HOC_SUSPEND_COUNT) { + // TODO: Guard against an infinite loop by throwing an error if the same + // component suspends too many times in a row. This should be thrown from + // the render phase so that it gets the component stack. + } +} + +export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex<T>( + index: number, +): Thenable<T> | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 4e965bb3f42d4..229756596cc16 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -7,17 +7,19 @@ * @flow */ +import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols'; + import type {Wakeable} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; -import type {Flags} from './ReactFiberFlags'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new'; import type {EventPriority} from './ReactEventPriorities.new'; import type { PendingTransitionCallbacks, PendingBoundaries, Transition, + TransitionAbort, } from './ReactFiberTracingMarkerComponent.new'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; @@ -86,10 +88,17 @@ import { import { createWorkInProgress, assignFiberPropertiesInDEV, + resetWorkInProgress, } from './ReactFiber.new'; import {isRootDehydrated} from './ReactFiberShellHydration'; import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext.new'; -import {NoMode, ProfileMode, ConcurrentMode} from './ReactTypeOfMode'; +import { + NoMode, + ProfileMode, + ConcurrentMode, + StrictLegacyMode, + StrictEffectsMode, +} from './ReactTypeOfMode'; import { HostRoot, IndeterminateComponent, @@ -103,7 +112,7 @@ import { SimpleMemoComponent, Profiler, } from './ReactWorkTags'; -import {LegacyRoot} from './ReactRootTags'; +import {ConcurrentRoot, LegacyRoot} from './ReactRootTags'; import { NoFlags, Incomplete, @@ -114,8 +123,7 @@ import { MutationMask, LayoutMask, PassiveMask, - MountPassiveDev, - MountLayoutDev, + PlacementDEV, } from './ReactFiberFlags'; import { NoLanes, @@ -175,10 +183,10 @@ import { commitPassiveEffectDurations, commitPassiveMountEffects, commitPassiveUnmountEffects, - invokeLayoutEffectMountInDEV, - invokePassiveEffectMountInDEV, - invokeLayoutEffectUnmountInDEV, - invokePassiveEffectUnmountInDEV, + disappearLayoutEffects, + reconnectPassiveEffects, + reappearLayoutEffects, + disconnectPassiveEffect, reportUncaughtErrorInDEV, } from './ReactFiberCommitWork.new'; import {enqueueUpdate} from './ReactFiberClassUpdateQueue.new'; @@ -245,9 +253,18 @@ import { isConcurrentActEnvironment, } from './ReactFiberAct.new'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new'; +import { + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, + trackSuspendedWakeable, + suspendedThenableDidResolve, + isTrackingSuspendedThenable, +} from './ReactFiberWakeable.new'; const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -260,7 +277,7 @@ type ExecutionContext = number; export const NoContext = /* */ 0b000; const BatchedContext = /* */ 0b001; const RenderContext = /* */ 0b010; -const CommitContext = /* */ 0b100; +export const CommitContext = /* */ 0b100; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; const RootInProgress = 0; @@ -280,6 +297,18 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; +// When this is true, the work-in-progress fiber just suspended (or errored) and +// we've yet to unwind the stack. In some cases, we may yield to the main thread +// after this happens. If the fiber is pinged before we resume, we can retry +// immediately instead of unwinding the stack. +let workInProgressIsSuspended: boolean = false; +let workInProgressThrownValue: mixed = null; + +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; + // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree // that is currently hidden, we add the lanes that would have committed if @@ -342,6 +371,7 @@ export function addTransitionStartCallbackToPendingTransition( transitionProgress: null, transitionComplete: null, markerProgress: null, + markerIncomplete: null, markerComplete: null, }; } @@ -357,7 +387,7 @@ export function addTransitionStartCallbackToPendingTransition( export function addMarkerProgressCallbackToPendingTransition( markerName: string, transitions: Set<Transition>, - pendingBoundaries: PendingBoundaries | null, + pendingBoundaries: PendingBoundaries, ) { if (enableTransitionTracing) { if (currentPendingTransitionCallbacks === null) { @@ -366,6 +396,7 @@ export function addMarkerProgressCallbackToPendingTransition( transitionProgress: null, transitionComplete: null, markerProgress: new Map(), + markerIncomplete: null, markerComplete: null, }; } @@ -381,6 +412,34 @@ export function addMarkerProgressCallbackToPendingTransition( } } +export function addMarkerIncompleteCallbackToPendingTransition( + markerName: string, + transitions: Set<Transition>, + aborts: Array<TransitionAbort>, +) { + if (enableTransitionTracing) { + if (currentPendingTransitionCallbacks === null) { + currentPendingTransitionCallbacks = { + transitionStart: null, + transitionProgress: null, + transitionComplete: null, + markerProgress: null, + markerIncomplete: new Map(), + markerComplete: null, + }; + } + + if (currentPendingTransitionCallbacks.markerIncomplete === null) { + currentPendingTransitionCallbacks.markerIncomplete = new Map(); + } + + currentPendingTransitionCallbacks.markerIncomplete.set(markerName, { + transitions, + aborts, + }); + } +} + export function addMarkerCompleteCallbackToPendingTransition( markerName: string, transitions: Set<Transition>, @@ -392,6 +451,7 @@ export function addMarkerCompleteCallbackToPendingTransition( transitionProgress: null, transitionComplete: null, markerProgress: null, + markerIncomplete: null, markerComplete: new Map(), }; } @@ -418,6 +478,7 @@ export function addTransitionProgressCallbackToPendingTransition( transitionProgress: new Map(), transitionComplete: null, markerProgress: null, + markerIncomplete: null, markerComplete: null, }; } @@ -443,6 +504,7 @@ export function addTransitionCompleteCallbackToPendingTransition( transitionProgress: null, transitionComplete: [], markerProgress: null, + markerIncomplete: null, markerComplete: null, }; } @@ -960,10 +1022,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1003,10 +1073,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1037,14 +1115,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1067,6 +1150,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1323,10 +1432,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1543,11 +1660,16 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1566,89 +1688,65 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleError(root, thrownValue): void { - do { - let erroredWork = workInProgress; - try { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooksAfterThrow(); - resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = null; - - if (erroredWork === null || erroredWork.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return; - } +function handleThrow(root, thrownValue): void { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooksAfterThrow(); + resetCurrentDebugFiberInDEV(); + // TODO: I found and added this missing line while investigating a + // separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = null; - if (enableProfilerTimer && erroredWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); - } + // Setting this to `true` tells the work loop to unwind the stack instead + // of entering the begin phase. It's called "suspended" because it usually + // happens because of Suspense, but it also applies to errors. Think of it + // as suspending the execution of the work loop. + workInProgressIsSuspended = true; + workInProgressThrownValue = thrownValue; + + const erroredWork = workInProgress; + if (erroredWork === null) { + // This is a fatal error + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + return; + } - if (enableSchedulingProfiler) { - markComponentRenderStopped(); + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function'; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - const wakeable: Wakeable = (thrownValue: any); - markComponentSuspended( - erroredWork, - wakeable, - workInProgressRootRenderLanes, - ); - } else { - markComponentErrored( - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } - } + if (enableProfilerTimer && erroredWork.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + } - throwException( - root, - erroredWork.return, + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + markComponentSuspended( + erroredWork, + wakeable, + workInProgressRootRenderLanes, + ); + } else { + markComponentErrored( erroredWork, thrownValue, workInProgressRootRenderLanes, ); - completeUnitOfWork(erroredWork); - } catch (yetAnotherThrownValue) { - // Something in the return path also threw. - thrownValue = yetAnotherThrownValue; - if (workInProgress === erroredWork && erroredWork !== null) { - // If this boundary has already errored, then we had trouble processing - // the error. Bubble it to the next boundary. - erroredWork = erroredWork.return; - workInProgress = erroredWork; - } else { - erroredWork = workInProgress; - } - continue; } - // Return to the normal work loop. - return; - } while (true); + } + + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + + trackSuspendedWakeable(wakeable); + } } function pushDispatcher() { @@ -1774,7 +1872,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { workLoopSync(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); } } while (true); resetContextDependencies(); @@ -1810,7 +1908,19 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { // The work loop is an extremely hot path. Tell Closure not to inline it. /** @noinline */ function workLoopSync() { - // Already timed out, so perform work without checking if we need to yield. + // Perform work without checking if we need to yield between fiber. + + if (workInProgressIsSuspended) { + // The current work-in-progress was already attempted. We need to unwind + // it before we continue the normal work loop. + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } + } + while (workInProgress !== null) { performUnitOfWork(workInProgress); } @@ -1860,7 +1970,13 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workLoopConcurrent(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); + if (isTrackingSuspendedThenable()) { + // If this fiber just suspended, it's possible the data is already + // cached. Yield to the the main thread to give it a chance to ping. If + // it does, we can retry immediately without unwinding the stack. + break; + } } } while (true); resetContextDependencies(); @@ -1899,6 +2015,18 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopConcurrent() { // Perform work until Scheduler asks us to yield + + if (workInProgressIsSuspended) { + // The current work-in-progress was already attempted. We need to unwind + // it before we continue the normal work loop. + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } + } + while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); } @@ -1932,6 +2060,100 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } +function resumeSuspendedUnitOfWork( + unitOfWork: Fiber, + thrownValue: mixed, +): void { + // This is a fork of performUnitOfWork specifcally for resuming a fiber that + // just suspended. In some cases, we may choose to retry the fiber immediately + // instead of unwinding the stack. It's a separate function to keep the + // additional logic out of the work loop's hot path. + + const wasPinged = suspendedThenableDidResolve(); + resetWakeableStateAfterEachAttempt(); + + if (!wasPinged) { + // The thenable wasn't pinged. Return to the normal work loop. This will + // unwind the stack, and potentially result in showing a fallback. + resetThenableStateOnCompletion(); + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. + completeUnitOfWork(unitOfWork); + return; + } + + // The work-in-progress was immediately pinged. Instead of unwinding the + // stack and potentially showing a fallback, unwind only the last stack frame, + // reset the fiber, and try rendering it again. + const current = unitOfWork.alternate; + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); + unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); + + setCurrentDebugFiberInDEV(unitOfWork); + + let next; + if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { + startProfilerTimer(unitOfWork); + next = beginWork(current, unitOfWork, renderLanes); + stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + } else { + next = beginWork(current, unitOfWork, renderLanes); + } + + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. + resetThenableStateOnCompletion(); + + resetCurrentDebugFiberInDEV(); + unitOfWork.memoizedProps = unitOfWork.pendingProps; + if (next === null) { + // If this doesn't spawn new work, complete the current work. + completeUnitOfWork(unitOfWork); + } else { + workInProgress = next; + } + + ReactCurrentOwner.current = null; +} + function completeUnitOfWork(unitOfWork: Fiber): void { // Attempt to complete the current unit of work, then move to the next // sibling. If there are no more siblings, return to the parent fiber. @@ -2331,7 +2553,7 @@ function commitRootImpl( if (__DEV__ && enableStrictEffects) { if (!rootDidHavePassiveEffects) { - commitDoubleInvokeEffectsInDEV(root.current, false); + commitDoubleInvokeEffectsInDEV(root); } } @@ -2548,7 +2770,7 @@ function flushPassiveEffectsImpl() { } if (__DEV__ && enableStrictEffects) { - commitDoubleInvokeEffectsInDEV(root.current, true); + commitDoubleInvokeEffectsInDEV(root); } executionContext = prevExecutionContext; @@ -2719,7 +2941,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, @@ -2743,7 +3011,6 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - // TODO: If we're rendering sync either due to Sync, Batched or expired, // we should probably never restart. @@ -2898,64 +3165,57 @@ function flushRenderPhaseStrictModeWarningsInDEV() { } } -function commitDoubleInvokeEffectsInDEV( - fiber: Fiber, - hasPassiveEffects: boolean, +function recursivelyTraverseAndDoubleInvokeEffectsInDEV( + root: FiberRoot, + parentFiber: Fiber, + isInStrictMode: boolean, ) { - if (__DEV__ && enableStrictEffects) { - // TODO (StrictEffects) Should we set a marker on the root if it contains strict effects - // so we don't traverse unnecessarily? similar to subtreeFlags but just at the root level. - // Maybe not a big deal since this is DEV only behavior. + let child = parentFiber.child; + while (child !== null) { + doubleInvokeEffectsInDEV(root, child, isInStrictMode); + child = child.sibling; + } +} +function doubleInvokeEffectsInDEV( + root: FiberRoot, + fiber: Fiber, + parentIsInStrictMode: boolean, +) { + const isStrictModeFiber = fiber.type === REACT_STRICT_MODE_TYPE; + const isInStrictMode = parentIsInStrictMode || isStrictModeFiber; + if (fiber.flags & PlacementDEV || fiber.tag === OffscreenComponent) { setCurrentDebugFiberInDEV(fiber); - invokeEffectsInDev(fiber, MountLayoutDev, invokeLayoutEffectUnmountInDEV); - if (hasPassiveEffects) { - invokeEffectsInDev( - fiber, - MountPassiveDev, - invokePassiveEffectUnmountInDEV, - ); - } - - invokeEffectsInDev(fiber, MountLayoutDev, invokeLayoutEffectMountInDEV); - if (hasPassiveEffects) { - invokeEffectsInDev(fiber, MountPassiveDev, invokePassiveEffectMountInDEV); + if (isInStrictMode) { + disappearLayoutEffects(fiber); + disconnectPassiveEffect(fiber); + reappearLayoutEffects(root, fiber.alternate, fiber, false); + reconnectPassiveEffects(root, fiber, NoLanes, null, false); } resetCurrentDebugFiberInDEV(); + } else { + recursivelyTraverseAndDoubleInvokeEffectsInDEV(root, fiber, isInStrictMode); } } -function invokeEffectsInDev( - firstChild: Fiber, - fiberFlags: Flags, - invokeEffectFn: (fiber: Fiber) => void, -): void { +function commitDoubleInvokeEffectsInDEV(root: FiberRoot) { if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - - let current = firstChild; - let subtreeRoot = null; - while (current !== null) { - const primarySubtreeFlag = current.subtreeFlags & fiberFlags; - if ( - current !== subtreeRoot && - current.child !== null && - primarySubtreeFlag !== NoFlags - ) { - current = current.child; - } else { - if ((current.flags & fiberFlags) !== NoFlags) { - invokeEffectFn(current); - } + let doubleInvokeEffects = true; - if (current.sibling !== null) { - current = current.sibling; - } else { - current = subtreeRoot = current.return; - } - } + if (root.tag === LegacyRoot && !(root.current.mode & StrictLegacyMode)) { + doubleInvokeEffects = false; } + if ( + root.tag === ConcurrentRoot && + !(root.current.mode & (StrictLegacyMode | StrictEffectsMode)) + ) { + doubleInvokeEffects = false; + } + recursivelyTraverseAndDoubleInvokeEffectsInDEV( + root, + root.current, + doubleInvokeEffects, + ); } } @@ -3044,7 +3304,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleError; any changes here must have + // Keep this code in sync with handleThrow; any changes here must have // corresponding changes there. resetContextDependencies(); resetHooksAfterThrow(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 4b2e523d40a21..c4caf55518a86 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -7,17 +7,19 @@ * @flow */ +import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols'; + import type {Wakeable} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; -import type {Flags} from './ReactFiberFlags'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old'; import type {EventPriority} from './ReactEventPriorities.old'; import type { PendingTransitionCallbacks, PendingBoundaries, Transition, + TransitionAbort, } from './ReactFiberTracingMarkerComponent.old'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; @@ -86,10 +88,17 @@ import { import { createWorkInProgress, assignFiberPropertiesInDEV, + resetWorkInProgress, } from './ReactFiber.old'; import {isRootDehydrated} from './ReactFiberShellHydration'; import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext.old'; -import {NoMode, ProfileMode, ConcurrentMode} from './ReactTypeOfMode'; +import { + NoMode, + ProfileMode, + ConcurrentMode, + StrictLegacyMode, + StrictEffectsMode, +} from './ReactTypeOfMode'; import { HostRoot, IndeterminateComponent, @@ -103,7 +112,7 @@ import { SimpleMemoComponent, Profiler, } from './ReactWorkTags'; -import {LegacyRoot} from './ReactRootTags'; +import {ConcurrentRoot, LegacyRoot} from './ReactRootTags'; import { NoFlags, Incomplete, @@ -114,8 +123,7 @@ import { MutationMask, LayoutMask, PassiveMask, - MountPassiveDev, - MountLayoutDev, + PlacementDEV, } from './ReactFiberFlags'; import { NoLanes, @@ -175,10 +183,10 @@ import { commitPassiveEffectDurations, commitPassiveMountEffects, commitPassiveUnmountEffects, - invokeLayoutEffectMountInDEV, - invokePassiveEffectMountInDEV, - invokeLayoutEffectUnmountInDEV, - invokePassiveEffectUnmountInDEV, + disappearLayoutEffects, + reconnectPassiveEffects, + reappearLayoutEffects, + disconnectPassiveEffect, reportUncaughtErrorInDEV, } from './ReactFiberCommitWork.old'; import {enqueueUpdate} from './ReactFiberClassUpdateQueue.old'; @@ -245,9 +253,18 @@ import { isConcurrentActEnvironment, } from './ReactFiberAct.old'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old'; +import { + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, + trackSuspendedWakeable, + suspendedThenableDidResolve, + isTrackingSuspendedThenable, +} from './ReactFiberWakeable.old'; const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -260,7 +277,7 @@ type ExecutionContext = number; export const NoContext = /* */ 0b000; const BatchedContext = /* */ 0b001; const RenderContext = /* */ 0b010; -const CommitContext = /* */ 0b100; +export const CommitContext = /* */ 0b100; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; const RootInProgress = 0; @@ -280,6 +297,18 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; +// When this is true, the work-in-progress fiber just suspended (or errored) and +// we've yet to unwind the stack. In some cases, we may yield to the main thread +// after this happens. If the fiber is pinged before we resume, we can retry +// immediately instead of unwinding the stack. +let workInProgressIsSuspended: boolean = false; +let workInProgressThrownValue: mixed = null; + +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; + // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree // that is currently hidden, we add the lanes that would have committed if @@ -342,6 +371,7 @@ export function addTransitionStartCallbackToPendingTransition( transitionProgress: null, transitionComplete: null, markerProgress: null, + markerIncomplete: null, markerComplete: null, }; } @@ -357,7 +387,7 @@ export function addTransitionStartCallbackToPendingTransition( export function addMarkerProgressCallbackToPendingTransition( markerName: string, transitions: Set<Transition>, - pendingBoundaries: PendingBoundaries | null, + pendingBoundaries: PendingBoundaries, ) { if (enableTransitionTracing) { if (currentPendingTransitionCallbacks === null) { @@ -366,6 +396,7 @@ export function addMarkerProgressCallbackToPendingTransition( transitionProgress: null, transitionComplete: null, markerProgress: new Map(), + markerIncomplete: null, markerComplete: null, }; } @@ -381,6 +412,34 @@ export function addMarkerProgressCallbackToPendingTransition( } } +export function addMarkerIncompleteCallbackToPendingTransition( + markerName: string, + transitions: Set<Transition>, + aborts: Array<TransitionAbort>, +) { + if (enableTransitionTracing) { + if (currentPendingTransitionCallbacks === null) { + currentPendingTransitionCallbacks = { + transitionStart: null, + transitionProgress: null, + transitionComplete: null, + markerProgress: null, + markerIncomplete: new Map(), + markerComplete: null, + }; + } + + if (currentPendingTransitionCallbacks.markerIncomplete === null) { + currentPendingTransitionCallbacks.markerIncomplete = new Map(); + } + + currentPendingTransitionCallbacks.markerIncomplete.set(markerName, { + transitions, + aborts, + }); + } +} + export function addMarkerCompleteCallbackToPendingTransition( markerName: string, transitions: Set<Transition>, @@ -392,6 +451,7 @@ export function addMarkerCompleteCallbackToPendingTransition( transitionProgress: null, transitionComplete: null, markerProgress: null, + markerIncomplete: null, markerComplete: new Map(), }; } @@ -418,6 +478,7 @@ export function addTransitionProgressCallbackToPendingTransition( transitionProgress: new Map(), transitionComplete: null, markerProgress: null, + markerIncomplete: null, markerComplete: null, }; } @@ -443,6 +504,7 @@ export function addTransitionCompleteCallbackToPendingTransition( transitionProgress: null, transitionComplete: [], markerProgress: null, + markerIncomplete: null, markerComplete: null, }; } @@ -960,10 +1022,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1003,10 +1073,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1037,14 +1115,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1067,6 +1150,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1323,10 +1432,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1543,11 +1660,16 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1566,89 +1688,65 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleError(root, thrownValue): void { - do { - let erroredWork = workInProgress; - try { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooksAfterThrow(); - resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = null; - - if (erroredWork === null || erroredWork.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return; - } +function handleThrow(root, thrownValue): void { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooksAfterThrow(); + resetCurrentDebugFiberInDEV(); + // TODO: I found and added this missing line while investigating a + // separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = null; - if (enableProfilerTimer && erroredWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); - } + // Setting this to `true` tells the work loop to unwind the stack instead + // of entering the begin phase. It's called "suspended" because it usually + // happens because of Suspense, but it also applies to errors. Think of it + // as suspending the execution of the work loop. + workInProgressIsSuspended = true; + workInProgressThrownValue = thrownValue; + + const erroredWork = workInProgress; + if (erroredWork === null) { + // This is a fatal error + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + return; + } - if (enableSchedulingProfiler) { - markComponentRenderStopped(); + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function'; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - const wakeable: Wakeable = (thrownValue: any); - markComponentSuspended( - erroredWork, - wakeable, - workInProgressRootRenderLanes, - ); - } else { - markComponentErrored( - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } - } + if (enableProfilerTimer && erroredWork.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + } - throwException( - root, - erroredWork.return, + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + markComponentSuspended( + erroredWork, + wakeable, + workInProgressRootRenderLanes, + ); + } else { + markComponentErrored( erroredWork, thrownValue, workInProgressRootRenderLanes, ); - completeUnitOfWork(erroredWork); - } catch (yetAnotherThrownValue) { - // Something in the return path also threw. - thrownValue = yetAnotherThrownValue; - if (workInProgress === erroredWork && erroredWork !== null) { - // If this boundary has already errored, then we had trouble processing - // the error. Bubble it to the next boundary. - erroredWork = erroredWork.return; - workInProgress = erroredWork; - } else { - erroredWork = workInProgress; - } - continue; } - // Return to the normal work loop. - return; - } while (true); + } + + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + + trackSuspendedWakeable(wakeable); + } } function pushDispatcher() { @@ -1774,7 +1872,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { workLoopSync(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); } } while (true); resetContextDependencies(); @@ -1810,7 +1908,19 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { // The work loop is an extremely hot path. Tell Closure not to inline it. /** @noinline */ function workLoopSync() { - // Already timed out, so perform work without checking if we need to yield. + // Perform work without checking if we need to yield between fiber. + + if (workInProgressIsSuspended) { + // The current work-in-progress was already attempted. We need to unwind + // it before we continue the normal work loop. + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } + } + while (workInProgress !== null) { performUnitOfWork(workInProgress); } @@ -1860,7 +1970,13 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workLoopConcurrent(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); + if (isTrackingSuspendedThenable()) { + // If this fiber just suspended, it's possible the data is already + // cached. Yield to the the main thread to give it a chance to ping. If + // it does, we can retry immediately without unwinding the stack. + break; + } } } while (true); resetContextDependencies(); @@ -1899,6 +2015,18 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopConcurrent() { // Perform work until Scheduler asks us to yield + + if (workInProgressIsSuspended) { + // The current work-in-progress was already attempted. We need to unwind + // it before we continue the normal work loop. + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } + } + while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); } @@ -1932,6 +2060,100 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } +function resumeSuspendedUnitOfWork( + unitOfWork: Fiber, + thrownValue: mixed, +): void { + // This is a fork of performUnitOfWork specifcally for resuming a fiber that + // just suspended. In some cases, we may choose to retry the fiber immediately + // instead of unwinding the stack. It's a separate function to keep the + // additional logic out of the work loop's hot path. + + const wasPinged = suspendedThenableDidResolve(); + resetWakeableStateAfterEachAttempt(); + + if (!wasPinged) { + // The thenable wasn't pinged. Return to the normal work loop. This will + // unwind the stack, and potentially result in showing a fallback. + resetThenableStateOnCompletion(); + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. + completeUnitOfWork(unitOfWork); + return; + } + + // The work-in-progress was immediately pinged. Instead of unwinding the + // stack and potentially showing a fallback, unwind only the last stack frame, + // reset the fiber, and try rendering it again. + const current = unitOfWork.alternate; + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); + unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); + + setCurrentDebugFiberInDEV(unitOfWork); + + let next; + if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { + startProfilerTimer(unitOfWork); + next = beginWork(current, unitOfWork, renderLanes); + stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + } else { + next = beginWork(current, unitOfWork, renderLanes); + } + + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. + resetThenableStateOnCompletion(); + + resetCurrentDebugFiberInDEV(); + unitOfWork.memoizedProps = unitOfWork.pendingProps; + if (next === null) { + // If this doesn't spawn new work, complete the current work. + completeUnitOfWork(unitOfWork); + } else { + workInProgress = next; + } + + ReactCurrentOwner.current = null; +} + function completeUnitOfWork(unitOfWork: Fiber): void { // Attempt to complete the current unit of work, then move to the next // sibling. If there are no more siblings, return to the parent fiber. @@ -2331,7 +2553,7 @@ function commitRootImpl( if (__DEV__ && enableStrictEffects) { if (!rootDidHavePassiveEffects) { - commitDoubleInvokeEffectsInDEV(root.current, false); + commitDoubleInvokeEffectsInDEV(root); } } @@ -2548,7 +2770,7 @@ function flushPassiveEffectsImpl() { } if (__DEV__ && enableStrictEffects) { - commitDoubleInvokeEffectsInDEV(root.current, true); + commitDoubleInvokeEffectsInDEV(root); } executionContext = prevExecutionContext; @@ -2719,7 +2941,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, @@ -2743,7 +3011,6 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - // TODO: If we're rendering sync either due to Sync, Batched or expired, // we should probably never restart. @@ -2898,64 +3165,57 @@ function flushRenderPhaseStrictModeWarningsInDEV() { } } -function commitDoubleInvokeEffectsInDEV( - fiber: Fiber, - hasPassiveEffects: boolean, +function recursivelyTraverseAndDoubleInvokeEffectsInDEV( + root: FiberRoot, + parentFiber: Fiber, + isInStrictMode: boolean, ) { - if (__DEV__ && enableStrictEffects) { - // TODO (StrictEffects) Should we set a marker on the root if it contains strict effects - // so we don't traverse unnecessarily? similar to subtreeFlags but just at the root level. - // Maybe not a big deal since this is DEV only behavior. + let child = parentFiber.child; + while (child !== null) { + doubleInvokeEffectsInDEV(root, child, isInStrictMode); + child = child.sibling; + } +} +function doubleInvokeEffectsInDEV( + root: FiberRoot, + fiber: Fiber, + parentIsInStrictMode: boolean, +) { + const isStrictModeFiber = fiber.type === REACT_STRICT_MODE_TYPE; + const isInStrictMode = parentIsInStrictMode || isStrictModeFiber; + if (fiber.flags & PlacementDEV || fiber.tag === OffscreenComponent) { setCurrentDebugFiberInDEV(fiber); - invokeEffectsInDev(fiber, MountLayoutDev, invokeLayoutEffectUnmountInDEV); - if (hasPassiveEffects) { - invokeEffectsInDev( - fiber, - MountPassiveDev, - invokePassiveEffectUnmountInDEV, - ); - } - - invokeEffectsInDev(fiber, MountLayoutDev, invokeLayoutEffectMountInDEV); - if (hasPassiveEffects) { - invokeEffectsInDev(fiber, MountPassiveDev, invokePassiveEffectMountInDEV); + if (isInStrictMode) { + disappearLayoutEffects(fiber); + disconnectPassiveEffect(fiber); + reappearLayoutEffects(root, fiber.alternate, fiber, false); + reconnectPassiveEffects(root, fiber, NoLanes, null, false); } resetCurrentDebugFiberInDEV(); + } else { + recursivelyTraverseAndDoubleInvokeEffectsInDEV(root, fiber, isInStrictMode); } } -function invokeEffectsInDev( - firstChild: Fiber, - fiberFlags: Flags, - invokeEffectFn: (fiber: Fiber) => void, -): void { +function commitDoubleInvokeEffectsInDEV(root: FiberRoot) { if (__DEV__ && enableStrictEffects) { - // We don't need to re-check StrictEffectsMode here. - // This function is only called if that check has already passed. - - let current = firstChild; - let subtreeRoot = null; - while (current !== null) { - const primarySubtreeFlag = current.subtreeFlags & fiberFlags; - if ( - current !== subtreeRoot && - current.child !== null && - primarySubtreeFlag !== NoFlags - ) { - current = current.child; - } else { - if ((current.flags & fiberFlags) !== NoFlags) { - invokeEffectFn(current); - } + let doubleInvokeEffects = true; - if (current.sibling !== null) { - current = current.sibling; - } else { - current = subtreeRoot = current.return; - } - } + if (root.tag === LegacyRoot && !(root.current.mode & StrictLegacyMode)) { + doubleInvokeEffects = false; } + if ( + root.tag === ConcurrentRoot && + !(root.current.mode & (StrictLegacyMode | StrictEffectsMode)) + ) { + doubleInvokeEffects = false; + } + recursivelyTraverseAndDoubleInvokeEffectsInDEV( + root, + root.current, + doubleInvokeEffects, + ); } } @@ -3044,7 +3304,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleError; any changes here must have + // Keep this code in sync with handleThrow; any changes here must have // corresponding changes there. resetContextDependencies(); resetHooksAfterThrow(); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index e017674dda205..c8239dc2483e0 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -17,6 +17,7 @@ import type { MutableSource, StartTransitionOptions, Wakeable, + Usable, } from 'shared/ReactTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {WorkTag} from './ReactWorkTags'; @@ -238,6 +239,7 @@ type BaseFiberRootProperties = {| pingedLanes: Lanes, expiredLanes: Lanes, mutableReadLanes: Lanes, + errorRecoveryDisabledLanes: Lanes, finishedLanes: Lanes, @@ -291,8 +293,7 @@ export type TransitionTracingCallbacks = { startTime: number, deletions: Array<{ type: string, - name?: string, - newName?: string, + name?: string | null, endTime: number, }>, ) => void, @@ -314,8 +315,7 @@ export type TransitionTracingCallbacks = { startTime: number, deletions: Array<{ type: string, - name?: string, - newName?: string, + name?: string | null, endTime: number, }>, ) => void, @@ -337,7 +337,7 @@ type TransitionTracingOnlyFiberRootProperties = {| // are considered complete when the pending suspense boundaries set is // empty. We can represent this as a Map of transitions to suspense // boundary sets - incompleteTransitions: Map<Array<Transition>, TracingMarkerInstance>, + incompleteTransitions: Map<Transition, TracingMarkerInstance>, |}; // Exported FiberRoot type includes all properties, @@ -355,6 +355,7 @@ type BasicStateAction<S> = (S => S) | S; type Dispatch<A> = A => void; export type Dispatcher = {| + use?: <T>(Usable<T>) => T, getCacheSignal?: () => AbortSignal, getCacheForType?: <T>(resourceType: () => T) => T, readContext<T>(context: ReactContext<T>): T, @@ -403,6 +404,7 @@ export type Dispatcher = {| ): T, useId(): string, useCacheRefresh?: () => <T>(?() => T, ?T) => void, + useMemoCache?: (size: number) => Array<any>, unstable_isNewReconciler?: boolean, |}; diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreenStrictMode-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreenStrictMode-test.js new file mode 100644 index 0000000000000..f750a9b7ef0d6 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactOffscreenStrictMode-test.js @@ -0,0 +1,95 @@ +let React; +let Offscreen; +let ReactNoop; +let act; +let log; + +describe('ReactOffscreenStrictMode', () => { + beforeEach(() => { + jest.resetModules(); + log = []; + + React = require('react'); + Offscreen = React.unstable_Offscreen; + ReactNoop = require('react-noop-renderer'); + act = require('jest-react').act; + }); + + function Component({label}) { + React.useEffect(() => { + log.push(`${label}: useEffect mount`); + return () => log.push(`${label}: useEffect unmount`); + }); + + React.useLayoutEffect(() => { + log.push(`${label}: useLayoutEffect mount`); + return () => log.push(`${label}: useLayoutEffect unmount`); + }); + + log.push(`${label}: render`); + + return <span>label</span>; + } + + // @gate __DEV__ && enableStrictEffects && enableOffscreen + it('should trigger strict effects when offscreen is visible', () => { + act(() => { + ReactNoop.render( + <React.StrictMode> + <Offscreen mode="visible"> + <Component label="A" /> + </Offscreen> + </React.StrictMode>, + ); + }); + + expect(log).toEqual([ + 'A: render', + 'A: render', + 'A: useLayoutEffect mount', + 'A: useEffect mount', + 'A: useLayoutEffect unmount', + 'A: useEffect unmount', + 'A: useLayoutEffect mount', + 'A: useEffect mount', + ]); + }); + + // @gate __DEV__ && enableStrictEffects && enableOffscreen + it('should not trigger strict effects when offscreen is hidden', () => { + act(() => { + ReactNoop.render( + <React.StrictMode> + <Offscreen mode="hidden"> + <Component label="A" /> + </Offscreen> + </React.StrictMode>, + ); + }); + + expect(log).toEqual(['A: render', 'A: render']); + + log = []; + + act(() => { + ReactNoop.render( + <React.StrictMode> + <Offscreen mode="visible"> + <Component label="A" /> + </Offscreen> + </React.StrictMode>, + ); + }); + + expect(log).toEqual([ + 'A: render', + 'A: render', + 'A: useLayoutEffect mount', + 'A: useEffect mount', + 'A: useLayoutEffect unmount', + 'A: useEffect unmount', + 'A: useLayoutEffect mount', + 'A: useEffect mount', + ]); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js index 290dd33b89a09..3c3a79a8efd74 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js @@ -485,22 +485,33 @@ describe('ReactOffscreen', () => { // In the same render, also hide the offscreen tree. root.render(<App show={false} />); - expect(Scheduler).toFlushUntilNextPaint([ - // The outer update will commit, but the inner update is deferred until - // a later render. - 'Outer: 1', - - // Something suspended. This means we won't commit immediately; there - // will be an async gap between render and commit. In this test, we will - // use this property to schedule a concurrent update. The fact that - // we're using Suspense to schedule a concurrent update is not directly - // relevant to the test — we could also use time slicing, but I've - // chosen to use Suspense the because implementation details of time - // slicing are more volatile. - 'Suspend! [Async: 1]', + if (gate(flags => flags.enableSyncDefaultUpdates)) { + expect(Scheduler).toFlushUntilNextPaint([ + // The outer update will commit, but the inner update is deferred until + // a later render. + 'Outer: 1', + + // Something suspended. This means we won't commit immediately; there + // will be an async gap between render and commit. In this test, we will + // use this property to schedule a concurrent update. The fact that + // we're using Suspense to schedule a concurrent update is not directly + // relevant to the test — we could also use time slicing, but I've + // chosen to use Suspense the because implementation details of time + // slicing are more volatile. + 'Suspend! [Async: 1]', + + 'Loading...', + ]); + } else { + // When default updates are time sliced, React yields before preparing + // the fallback. + expect(Scheduler).toFlushUntilNextPaint([ + 'Outer: 1', + 'Suspend! [Async: 1]', + ]); + expect(Scheduler).toFlushUntilNextPaint(['Loading...']); + } - 'Loading...', - ]); // Assert that we haven't committed quite yet expect(root).toMatchRenderedOutput( <> diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 3f2f4cc38aa09..07838b223e687 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -963,33 +963,36 @@ describe('ReactSuspenseWithNoopRenderer', () => { // @gate enableCache it('resolves successfully even if fallback render is pending', async () => { - ReactNoop.render( + const root = ReactNoop.createRoot(); + root.render( <> <Suspense fallback={<Text text="Loading..." />} /> </>, ); expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([]); + expect(root).toMatchRenderedOutput(null); if (gate(flags => flags.enableSyncDefaultUpdates)) { React.startTransition(() => { - ReactNoop.render( + root.render( <> <Suspense fallback={<Text text="Loading..." />}> <AsyncText text="Async" /> + <Text text="Sibling" /> </Suspense> </>, ); }); } else { - ReactNoop.render( + root.render( <> <Suspense fallback={<Text text="Loading..." />}> <AsyncText text="Async" /> + <Text text="Sibling" /> </Suspense> </>, ); } - expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']); + expect(Scheduler).toFlushAndYieldThrough(['Suspend! [Async]', 'Sibling']); await resolveText('Async'); expect(Scheduler).toFlushAndYield([ @@ -998,8 +1001,14 @@ describe('ReactSuspenseWithNoopRenderer', () => { 'Loading...', // Once we've completed the boundary we restarted. 'Async', + 'Sibling', ]); - expect(ReactNoop.getChildren()).toEqual([span('Async')]); + expect(root).toMatchRenderedOutput( + <> + <span prop="Async" /> + <span prop="Sibling" /> + </>, + ); }); // @gate enableCache @@ -3859,7 +3868,6 @@ describe('ReactSuspenseWithNoopRenderer', () => { 'Suspend! [A2]', 'Loading...', 'Suspend! [B2]', - 'Loading...', ]); expect(root).toMatchRenderedOutput( <> diff --git a/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js b/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js index ae4812afa8ec2..a6637b55977c9 100644 --- a/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js +++ b/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js @@ -23,6 +23,17 @@ let caches; let seededCache; describe('ReactInteractionTracing', () => { + function stringifyDeletions(deletions) { + return deletions + .map( + d => + `{${Object.keys(d) + .map(key => `${key}: ${d[key]}`) + .sort() + .join(', ')}}`, + ) + .join(', '); + } beforeEach(() => { jest.resetModules(); @@ -1284,18 +1295,846 @@ describe('ReactInteractionTracing', () => { }); // @gate enableTransitionTracing - it('warns when marker name changes', async () => { + it.skip('warn and calls marker incomplete if name changes before transition completes', async () => { + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + Scheduler.unstable_yieldValue( + `onTransitionStart(${name}, ${startTime})`, + ); + }, + onTransitionProgress: (name, startTime, endTime, pending) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onTransitionProgress(${name}, ${startTime}, ${endTime}, [${suspenseNames}])`, + ); + }, + onTransitionComplete: (name, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onTransitionComplete(${name}, ${startTime}, ${endTime})`, + ); + }, + onMarkerProgress: ( + transitioName, + markerName, + startTime, + currentTime, + pending, + ) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onMarkerProgress(${transitioName}, ${markerName}, ${startTime}, ${currentTime}, [${suspenseNames}])`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, + onMarkerComplete: (transitioName, markerName, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, + ); + }, + }; + + function App({navigate, markerName}) { + return ( + <div> + {navigate ? ( + <React.unstable_TracingMarker name={markerName}> + <Suspense fallback={<Text text="Loading..." />}> + <AsyncText text="Page Two" /> + </Suspense> + </React.unstable_TracingMarker> + ) : ( + <Text text="Page One" /> + )} + </div> + ); + } + + const root = ReactNoop.createRoot({ + unstable_transitionCallbacks: transitionCallbacks, + }); + await act(async () => { + root.render(<App navigate={false} markerName="marker one" />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['Page One']); + + startTransition( + () => root.render(<App navigate={true} markerName="marker one" />), + { + name: 'transition one', + }, + ); + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading...', + 'onTransitionStart(transition one, 1000)', + 'onMarkerProgress(transition one, marker one, 1000, 2000, [<null>])', + 'onTransitionProgress(transition one, 1000, 2000, [<null>])', + ]); + + root.render(<App navigate={true} markerName="marker two" />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(() => + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading...', + 'onMarkerIncomplete(transition one, marker one, 1000, [{endTime: 3000, name: marker one, newName: marker two, type: marker}])', + ]), + ).toErrorDev(''); + + resolveText('Page Two'); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Page Two', + 'onMarkerProgress(transition one, marker one, 1000, 4000, [])', + 'onTransitionProgress(transition one, 1000, 4000, [])', + 'onTransitionComplete(transition one, 1000, 4000)', + ]); + }); + }); + + // @gate enableTransitionTracing + it('marker incomplete for tree with parent and sibling tracing markers', async () => { + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + Scheduler.unstable_yieldValue( + `onTransitionStart(${name}, ${startTime})`, + ); + }, + onTransitionProgress: (name, startTime, endTime, pending) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onTransitionProgress(${name}, ${startTime}, ${endTime}, [${suspenseNames}])`, + ); + }, + onTransitionComplete: (name, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onTransitionComplete(${name}, ${startTime}, ${endTime})`, + ); + }, + onMarkerProgress: ( + transitioName, + markerName, + startTime, + currentTime, + pending, + ) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onMarkerProgress(${transitioName}, ${markerName}, ${startTime}, ${currentTime}, [${suspenseNames}])`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, + onMarkerComplete: (transitioName, markerName, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, + ); + }, + }; + + function App({navigate, showMarker}) { + return ( + <div> + {navigate ? ( + <React.unstable_TracingMarker name="parent"> + {showMarker ? ( + <React.unstable_TracingMarker name="marker one"> + <Suspense + unstable_name="suspense page" + fallback={<Text text="Loading..." />}> + <AsyncText text="Page Two" /> + </Suspense> + </React.unstable_TracingMarker> + ) : ( + <Suspense + unstable_name="suspense page" + fallback={<Text text="Loading..." />}> + <AsyncText text="Page Two" /> + </Suspense> + )} + <React.unstable_TracingMarker name="sibling"> + <Suspense + unstable_name="suspense sibling" + fallback={<Text text="Sibling Loading..." />}> + <AsyncText text="Sibling Text" /> + </Suspense> + </React.unstable_TracingMarker> + </React.unstable_TracingMarker> + ) : ( + <Text text="Page One" /> + )} + </div> + ); + } + + const root = ReactNoop.createRoot({ + unstable_transitionCallbacks: transitionCallbacks, + }); + await act(async () => { + root.render(<App navigate={false} showMarker={true} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['Page One']); + + startTransition( + () => root.render(<App navigate={true} showMarker={true} />), + { + name: 'transition one', + }, + ); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading...', + 'Suspend [Sibling Text]', + 'Sibling Loading...', + 'onTransitionStart(transition one, 1000)', + 'onMarkerProgress(transition one, parent, 1000, 2000, [suspense page, suspense sibling])', + 'onMarkerProgress(transition one, marker one, 1000, 2000, [suspense page])', + 'onMarkerProgress(transition one, sibling, 1000, 2000, [suspense sibling])', + 'onTransitionProgress(transition one, 1000, 2000, [suspense page, suspense sibling])', + ]); + root.render(<App navigate={true} showMarker={false} />); + + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading...', + 'Suspend [Sibling Text]', + 'Sibling Loading...', + 'onMarkerProgress(transition one, parent, 1000, 3000, [suspense sibling])', + 'onMarkerIncomplete(transition one, marker one, 1000, [{endTime: 3000, name: marker one, type: marker}, {endTime: 3000, name: suspense page, type: suspense}])', + 'onMarkerIncomplete(transition one, parent, 1000, [{endTime: 3000, name: marker one, type: marker}, {endTime: 3000, name: suspense page, type: suspense}])', + ]); + + root.render(<App navigate={true} showMarker={true} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading...', + 'Suspend [Sibling Text]', + 'Sibling Loading...', + ]); + }); + + resolveText('Page Two'); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['Page Two']); + + resolveText('Sibling Text'); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Sibling Text', + 'onMarkerProgress(transition one, parent, 1000, 6000, [])', + 'onMarkerProgress(transition one, sibling, 1000, 6000, [])', + // Calls markerComplete and transitionComplete for all parents + 'onMarkerComplete(transition one, sibling, 1000, 6000)', + 'onTransitionProgress(transition one, 1000, 6000, [])', + ]); + }); + + // @gate enableTransitionTracing + it('marker gets deleted', async () => { const transitionCallbacks = { onTransitionStart: (name, startTime) => { Scheduler.unstable_yieldValue( `onTransitionStart(${name}, ${startTime})`, ); }, + onTransitionProgress: (name, startTime, endTime, pending) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onTransitionProgress(${name}, ${startTime}, ${endTime}, [${suspenseNames}])`, + ); + }, onTransitionComplete: (name, startTime, endTime) => { Scheduler.unstable_yieldValue( `onTransitionComplete(${name}, ${startTime}, ${endTime})`, ); }, + onMarkerProgress: ( + transitioName, + markerName, + startTime, + currentTime, + pending, + ) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onMarkerProgress(${transitioName}, ${markerName}, ${startTime}, ${currentTime}, [${suspenseNames}])`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, + onMarkerComplete: (transitioName, markerName, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, + ); + }, + }; + + function App({navigate, deleteOne}) { + return ( + <div> + {navigate ? ( + <React.unstable_TracingMarker name="parent"> + {!deleteOne ? ( + <div> + <React.unstable_TracingMarker name="one"> + <Suspense + unstable_name="suspense one" + fallback={<Text text="Loading One..." />}> + <AsyncText text="Page One" /> + </Suspense> + </React.unstable_TracingMarker> + </div> + ) : null} + <React.unstable_TracingMarker name="two"> + <Suspense + unstable_name="suspense two" + fallback={<Text text="Loading Two..." />}> + <AsyncText text="Page Two" /> + </Suspense> + </React.unstable_TracingMarker> + </React.unstable_TracingMarker> + ) : ( + <Text text="Page One" /> + )} + </div> + ); + } + const root = ReactNoop.createRoot({ + unstable_transitionCallbacks: transitionCallbacks, + }); + await act(async () => { + root.render(<App navigate={false} deleteOne={false} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['Page One']); + + startTransition( + () => root.render(<App navigate={true} deleteOne={false} />), + { + name: 'transition', + }, + ); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page One]', + 'Loading One...', + 'Suspend [Page Two]', + 'Loading Two...', + 'onTransitionStart(transition, 1000)', + 'onMarkerProgress(transition, parent, 1000, 2000, [suspense one, suspense two])', + 'onMarkerProgress(transition, one, 1000, 2000, [suspense one])', + 'onMarkerProgress(transition, two, 1000, 2000, [suspense two])', + 'onTransitionProgress(transition, 1000, 2000, [suspense one, suspense two])', + ]); + + root.render(<App navigate={true} deleteOne={true} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading Two...', + 'onMarkerProgress(transition, parent, 1000, 3000, [suspense two])', + 'onMarkerIncomplete(transition, one, 1000, [{endTime: 3000, name: one, type: marker}, {endTime: 3000, name: suspense one, type: suspense}])', + 'onMarkerIncomplete(transition, parent, 1000, [{endTime: 3000, name: one, type: marker}, {endTime: 3000, name: suspense one, type: suspense}])', + ]); + + await resolveText('Page Two'); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Page Two', + // Marker progress will still get called after incomplete but not marker complete + 'onMarkerProgress(transition, parent, 1000, 4000, [])', + 'onMarkerProgress(transition, two, 1000, 4000, [])', + 'onMarkerComplete(transition, two, 1000, 4000)', + // Transition progress will still get called after incomplete but not transition complete + 'onTransitionProgress(transition, 1000, 4000, [])', + ]); + }); + }); + + // @gate enableTransitionTracing + it('Suspense boundary added by the transition is deleted', async () => { + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + Scheduler.unstable_yieldValue( + `onTransitionStart(${name}, ${startTime})`, + ); + }, + onTransitionProgress: (name, startTime, endTime, pending) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onTransitionProgress(${name}, ${startTime}, ${endTime}, [${suspenseNames}])`, + ); + }, + onTransitionComplete: (name, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onTransitionComplete(${name}, ${startTime}, ${endTime})`, + ); + }, + onMarkerProgress: ( + transitioName, + markerName, + startTime, + currentTime, + pending, + ) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onMarkerProgress(${transitioName}, ${markerName}, ${startTime}, ${currentTime}, [${suspenseNames}])`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, + onMarkerComplete: (transitioName, markerName, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, + ); + }, + }; + + function App({navigate, deleteOne}) { + return ( + <div> + {navigate ? ( + <React.unstable_TracingMarker name="parent"> + <React.unstable_TracingMarker name="one"> + {!deleteOne ? ( + <Suspense + unstable_name="suspense one" + fallback={<Text text="Loading One..." />}> + <AsyncText text="Page One" /> + <React.unstable_TracingMarker name="page one" /> + <Suspense + unstable_name="suspense child" + fallback={<Text text="Loading Child..." />}> + <React.unstable_TracingMarker name="child" /> + <AsyncText text="Child" /> + </Suspense> + </Suspense> + ) : null} + </React.unstable_TracingMarker> + <React.unstable_TracingMarker name="two"> + <Suspense + unstable_name="suspense two" + fallback={<Text text="Loading Two..." />}> + <AsyncText text="Page Two" /> + </Suspense> + </React.unstable_TracingMarker> + </React.unstable_TracingMarker> + ) : ( + <Text text="Page One" /> + )} + </div> + ); + } + const root = ReactNoop.createRoot({ + unstable_transitionCallbacks: transitionCallbacks, + }); + await act(async () => { + root.render(<App navigate={false} deleteOne={false} />); + + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['Page One']); + + startTransition( + () => root.render(<App navigate={true} deleteOne={false} />), + { + name: 'transition', + }, + ); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page One]', + 'Suspend [Child]', + 'Loading Child...', + 'Loading One...', + 'Suspend [Page Two]', + 'Loading Two...', + 'onTransitionStart(transition, 1000)', + 'onMarkerProgress(transition, parent, 1000, 2000, [suspense one, suspense two])', + 'onMarkerProgress(transition, one, 1000, 2000, [suspense one])', + 'onMarkerProgress(transition, two, 1000, 2000, [suspense two])', + 'onTransitionProgress(transition, 1000, 2000, [suspense one, suspense two])', + ]); + + await resolveText('Page One'); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Page One', + 'Suspend [Child]', + 'Loading Child...', + 'onMarkerProgress(transition, parent, 1000, 3000, [suspense two, suspense child])', + 'onMarkerProgress(transition, one, 1000, 3000, [suspense child])', + 'onMarkerComplete(transition, page one, 1000, 3000)', + 'onTransitionProgress(transition, 1000, 3000, [suspense two, suspense child])', + ]); + + root.render(<App navigate={true} deleteOne={true} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Suspend [Page Two]', + 'Loading Two...', + // "suspense one" has unsuspended so shouldn't be included + // tracing marker "page one" has completed so shouldn't be included + // all children of "suspense child" haven't yet been rendered so shouldn't be included + 'onMarkerProgress(transition, one, 1000, 4000, [])', + 'onMarkerProgress(transition, parent, 1000, 4000, [suspense two])', + 'onMarkerIncomplete(transition, one, 1000, [{endTime: 4000, name: suspense child, type: suspense}])', + 'onMarkerIncomplete(transition, parent, 1000, [{endTime: 4000, name: suspense child, type: suspense}])', + ]); + + await resolveText('Page Two'); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield([ + 'Page Two', + 'onMarkerProgress(transition, parent, 1000, 5000, [])', + 'onMarkerProgress(transition, two, 1000, 5000, [])', + 'onMarkerComplete(transition, two, 1000, 5000)', + 'onTransitionProgress(transition, 1000, 5000, [])', + ]); + }); + }); + + // @gate enableTransitionTracing + it('Suspense boundary not added by the transition is deleted ', async () => { + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + Scheduler.unstable_yieldValue( + `onTransitionStart(${name}, ${startTime})`, + ); + }, + onTransitionProgress: (name, startTime, endTime, pending) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onTransitionProgress(${name}, ${startTime}, ${endTime}, [${suspenseNames}])`, + ); + }, + onTransitionComplete: (name, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onTransitionComplete(${name}, ${startTime}, ${endTime})`, + ); + }, + onMarkerProgress: ( + transitioName, + markerName, + startTime, + currentTime, + pending, + ) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onMarkerProgress(${transitioName}, ${markerName}, ${startTime}, ${currentTime}, [${suspenseNames}])`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, + onMarkerComplete: (transitioName, markerName, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, + ); + }, + }; + + function App({show}) { + return ( + <React.unstable_TracingMarker name="parent"> + {show ? ( + <Suspense unstable_name="appended child"> + <AsyncText text="Appended child" /> + </Suspense> + ) : null} + <Suspense unstable_name="child"> + <AsyncText text="Child" /> + </Suspense> + </React.unstable_TracingMarker> + ); + } + + const root = ReactNoop.createRoot({ + unstable_transitionCallbacks: transitionCallbacks, + }); + await act(async () => { + startTransition(() => root.render(<App show={false} />), { + name: 'transition', + }); + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield([ + 'Suspend [Child]', + 'onTransitionStart(transition, 0)', + 'onMarkerProgress(transition, parent, 0, 1000, [child])', + 'onTransitionProgress(transition, 0, 1000, [child])', + ]); + + root.render(<App show={true} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + // This appended child isn't part of the transition so we + // don't call any callback + expect(Scheduler).toFlushAndYield([ + 'Suspend [Appended child]', + 'Suspend [Child]', + ]); + + // This deleted child isn't part of the transition so we + // don't call any callbacks + root.render(<App show={false} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['Suspend [Child]']); + + await resolveText('Child'); + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield([ + 'Child', + 'onMarkerProgress(transition, parent, 0, 4000, [])', + 'onMarkerComplete(transition, parent, 0, 4000)', + 'onTransitionProgress(transition, 0, 4000, [])', + 'onTransitionComplete(transition, 0, 4000)', + ]); + }); + }); + + // @gate enableTransitionTracing + it('marker incomplete gets called properly if child suspense marker is not part of it', async () => { + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + Scheduler.unstable_yieldValue( + `onTransitionStart(${name}, ${startTime})`, + ); + }, + onTransitionProgress: (name, startTime, endTime, pending) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onTransitionProgress(${name}, ${startTime}, ${endTime}, [${suspenseNames}])`, + ); + }, + onTransitionComplete: (name, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onTransitionComplete(${name}, ${startTime}, ${endTime})`, + ); + }, + onMarkerProgress: ( + transitioName, + markerName, + startTime, + currentTime, + pending, + ) => { + const suspenseNames = pending.map(p => p.name || '<null>').join(', '); + Scheduler.unstable_yieldValue( + `onMarkerProgress(${transitioName}, ${markerName}, ${startTime}, ${currentTime}, [${suspenseNames}])`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, + onMarkerComplete: (transitioName, markerName, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, + ); + }, + }; + + function App({show, showSuspense}) { + return ( + <React.unstable_TracingMarker name="parent"> + {show ? ( + <React.unstable_TracingMarker name="appended child"> + {showSuspense ? ( + <Suspense unstable_name="appended child"> + <AsyncText text="Appended child" /> + </Suspense> + ) : null} + </React.unstable_TracingMarker> + ) : null} + <Suspense unstable_name="child"> + <AsyncText text="Child" /> + </Suspense> + </React.unstable_TracingMarker> + ); + } + + const root = ReactNoop.createRoot({ + unstable_transitionCallbacks: transitionCallbacks, + }); + + await act(async () => { + startTransition( + () => root.render(<App show={false} showSuspense={false} />), + { + name: 'transition one', + }, + ); + + ReactNoop.expire(1000); + await advanceTimers(1000); + }); + + expect(Scheduler).toHaveYielded([ + 'Suspend [Child]', + 'onTransitionStart(transition one, 0)', + 'onMarkerProgress(transition one, parent, 0, 1000, [child])', + 'onTransitionProgress(transition one, 0, 1000, [child])', + ]); + + await act(async () => { + startTransition( + () => root.render(<App show={true} showSuspense={true} />), + { + name: 'transition two', + }, + ); + + ReactNoop.expire(1000); + await advanceTimers(1000); + }); + + expect(Scheduler).toHaveYielded([ + 'Suspend [Appended child]', + 'Suspend [Child]', + 'onTransitionStart(transition two, 1000)', + 'onMarkerProgress(transition two, appended child, 1000, 2000, [appended child])', + 'onTransitionProgress(transition two, 1000, 2000, [appended child])', + ]); + + await act(async () => { + root.render(<App show={true} showSuspense={false} />); + ReactNoop.expire(1000); + await advanceTimers(1000); + }); + + expect(Scheduler).toHaveYielded([ + 'Suspend [Child]', + 'onMarkerProgress(transition two, appended child, 1000, 3000, [])', + 'onMarkerIncomplete(transition two, appended child, 1000, [{endTime: 3000, name: appended child, type: suspense}])', + ]); + + await act(async () => { + resolveText('Child'); + ReactNoop.expire(1000); + await advanceTimers(1000); + }); + + expect(Scheduler).toHaveYielded([ + 'Child', + 'onMarkerProgress(transition one, parent, 0, 4000, [])', + 'onMarkerComplete(transition one, parent, 0, 4000)', + 'onTransitionProgress(transition one, 0, 4000, [])', + 'onTransitionComplete(transition one, 0, 4000)', + ]); + }); + + // @gate enableTransitionTracing + it('warns when marker name changes', async () => { + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + Scheduler.unstable_yieldValue( + `onTransitionStart(${name}, ${startTime})`, + ); + }, + onTransitionComplete: (name, startTime, endTime) => { + Scheduler.unstable_yieldValue( + `onTransitionComplete(${name}, ${startTime}, ${endTime})`, + ); + }, + onMarkerIncomplete: ( + transitionName, + markerName, + startTime, + deletions, + ) => { + Scheduler.unstable_yieldValue( + `onMarkerIncomplete(${transitionName}, ${markerName}, ${startTime}, [${stringifyDeletions( + deletions, + )}])`, + ); + }, onMarkerComplete: (transitioName, markerName, startTime, endTime) => { Scheduler.unstable_yieldValue( `onMarkerComplete(${transitioName}, ${markerName}, ${startTime}, ${endTime})`, diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js new file mode 100644 index 0000000000000..7d946e5cbd03c --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -0,0 +1,318 @@ +'use strict'; + +let React; +let ReactNoop; +let Scheduler; +let act; +let use; +let Suspense; +let startTransition; + +describe('ReactWakeable', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('jest-react').act; + use = React.experimental_use; + Suspense = React.Suspense; + startTransition = React.startTransition; + }); + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return props.text; + } + + test('if suspended fiber is pinged in a microtask, retry immediately without unwinding the stack', async () => { + let resolved = false; + function Async() { + if (resolved) { + return <Text text="Async" />; + } + Scheduler.unstable_yieldValue('Suspend!'); + throw Promise.resolve().then(() => { + Scheduler.unstable_yieldValue('Resolve in microtask'); + resolved = true; + }); + } + + function App() { + return ( + <Suspense fallback={<Text text="Loading..." />}> + <Async /> + </Suspense> + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(<App />); + }); + }); + + expect(Scheduler).toHaveYielded([ + // React will yield when the async component suspends. + 'Suspend!', + 'Resolve in microtask', + + // Finished rendering without unwinding the stack or preparing a fallback. + 'Async', + ]); + expect(root).toMatchRenderedOutput('Async'); + }); + + test('if suspended fiber is pinged in a microtask, it does not block a transition from completing', async () => { + let resolved = false; + function Async() { + if (resolved) { + return <Text text="Async" />; + } + Scheduler.unstable_yieldValue('Suspend!'); + throw Promise.resolve().then(() => { + Scheduler.unstable_yieldValue('Resolve in microtask'); + resolved = true; + }); + } + + function App() { + return <Async />; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(<App />); + }); + }); + expect(Scheduler).toHaveYielded([ + 'Suspend!', + 'Resolve in microtask', + 'Async', + ]); + expect(root).toMatchRenderedOutput('Async'); + }); + + test('does not infinite loop if already resolved thenable is thrown', async () => { + // An already resolved promise should never be thrown. Since it already + // resolved, we shouldn't bother trying to render again — doing so would + // likely lead to an infinite loop. This scenario should only happen if a + // userspace Suspense library makes an implementation mistake. + + // Create an already resolved thenable + const thenable = { + then(ping) {}, + status: 'fulfilled', + value: null, + }; + + let i = 0; + function Async() { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + Scheduler.unstable_yieldValue('Suspend!'); + // This thenable should never be thrown because it already resolved. + // But if it is thrown, React should handle it gracefully. + throw thenable; + } + + function App() { + return ( + <Suspense fallback={<Text text="Loading..." />}> + <Async /> + </Suspense> + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(<App />); + }); + expect(Scheduler).toHaveYielded(['Suspend!', 'Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + }); + + // @gate enableUseHook + test('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return <Text text={text} />; + } + + function App() { + return ( + <Suspense fallback={<Text text="Loading..." />}> + <Async /> + </Suspense> + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(<App />); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test("using a promise that's not cached between attempts", async () => { + function Async() { + const text = + use(Promise.resolve('A')) + + use(Promise.resolve('B')) + + use(Promise.resolve('C')); + return <Text text={text} />; + } + + function App() { + return ( + <Suspense fallback={<Text text="Loading..." />}> + <Async /> + </Suspense> + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(<App />); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test('using a rejected promise will throw', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return <Text text={this.state.error.message} />; + } + return this.props.children; + } + } + + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); + + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return <Text text={text} />; + } + + function App() { + return ( + <ErrorBoundary> + <Async /> + </ErrorBoundary> + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(<App />); + }); + }); + expect(Scheduler).toHaveYielded(['Oops!', 'Oops!']); + }); + + // @gate enableUseHook + test('erroring in the same component as an uncached promise does not result in an infinite loop', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return <Text text={'Caught an error: ' + this.state.error.message} />; + } + return this.props.children; + } + } + + let i = 0; + function Async({ + // Intentionally destrucutring a prop here so that our production error + // stack trick is triggered at the beginning of the function + prop, + }) { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + try { + use(Promise.resolve('Async')); + } catch (e) { + Scheduler.unstable_yieldValue('Suspend! [Async]'); + throw e; + } + throw new Error('Oops!'); + } + + function App() { + return ( + <Suspense fallback={<Text text="Loading..." />}> + <ErrorBoundary> + <Async /> + </ErrorBoundary> + </Suspense> + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(<App />); + }); + }); + expect(Scheduler).toHaveYielded([ + // First attempt. The uncached promise suspends. + 'Suspend! [Async]', + // Because the promise already resolved, we're able to unwrap the value + // immediately in a microtask. + // + // Then we proceed to the rest of the component, which throws an error. + 'Caught an error: Oops!', + + // During the sync error recovery pass, the component suspends, because + // we were unable to unwrap the value of the promise. + 'Suspend! [Async]', + 'Loading...', + + // Because the error recovery attempt suspended, React can't tell if the + // error was actually fixed, or it was masked by the suspended data. + // In this case, it wasn't actually fixed, so if we were to commit the + // suspended fallback, it would enter an endless error recovery loop. + // + // Instead, we disable error recovery for these lanes and start + // over again. + + // This time, the error is thrown and we commit the result. + 'Suspend! [Async]', + 'Caught an error: Oops!', + ]); + expect(root).toMatchRenderedOutput('Caught an error: Oops!'); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/StrictEffectsModeDefaults-test.internal.js b/packages/react-reconciler/src/__tests__/StrictEffectsModeDefaults-test.internal.js index f2041b4ba1e32..54150ad2d4be1 100644 --- a/packages/react-reconciler/src/__tests__/StrictEffectsModeDefaults-test.internal.js +++ b/packages/react-reconciler/src/__tests__/StrictEffectsModeDefaults-test.internal.js @@ -153,10 +153,8 @@ describe('StrictEffectsMode defaults', () => { </>, ); - expect(Scheduler).toFlushAndYieldThrough([ - 'useLayoutEffect mount "one"', - ]); expect(Scheduler).toFlushAndYield([ + 'useLayoutEffect mount "one"', 'useEffect mount "one"', 'useLayoutEffect unmount "one"', 'useEffect unmount "one"', @@ -381,6 +379,29 @@ describe('StrictEffectsMode defaults', () => { expect(Scheduler).toHaveYielded([]); }); + it('disconnects refs during double invoking', () => { + const onRefMock = jest.fn(); + function App({text}) { + return ( + <span + ref={ref => { + onRefMock(ref); + }}> + text + </span> + ); + } + + act(() => { + ReactNoop.render(<App text={'mount'} />); + }); + + expect(onRefMock.mock.calls.length).toBe(3); + expect(onRefMock.mock.calls[0][0]).not.toBeNull(); + expect(onRefMock.mock.calls[1][0]).toBe(null); + expect(onRefMock.mock.calls[2][0]).not.toBeNull(); + }); + it('passes the right context to class component lifecycles', () => { class App extends React.PureComponent { test() {} diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 8afc9a3aa2cb9..9bb5d6e271c49 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -186,3 +186,6 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; +export const isHydratableResource = $$$hostConfig.isHydratableResource; +export const getMatchingResourceInstance = + $$$hostConfig.getMatchingResourceInstance; diff --git a/packages/react-server-dom-webpack/node-register.js b/packages/react-server-dom-webpack/node-register.js index 03754438bf338..e0490f3a17686 100644 --- a/packages/react-server-dom-webpack/node-register.js +++ b/packages/react-server-dom-webpack/node-register.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightWebpackNodeRegister'; +module.exports = require('./src/ReactFlightWebpackNodeRegister'); diff --git a/packages/react-server-dom-webpack/package.json b/packages/react-server-dom-webpack/package.json index 0a8c3389de711..45ba3669f90af 100644 --- a/packages/react-server-dom-webpack/package.json +++ b/packages/react-server-dom-webpack/package.json @@ -35,6 +35,7 @@ "./writer.browser.server": "./writer.browser.server.js", "./node-loader": "./esm/react-server-dom-webpack-node-loader.js", "./node-register": "./node-register.js", + "./src/*": "./src/*", "./package.json": "./package.json" }, "main": "index.js", diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index d36642532f56a..288f63bfdc92c 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -7,6 +7,8 @@ * @flow */ +import type {Thenable} from 'shared/ReactTypes'; + export type WebpackSSRMap = { [clientId: string]: { [clientExportName: string]: ModuleMetaData, @@ -19,6 +21,7 @@ export opaque type ModuleMetaData = { id: string, chunks: Array<string>, name: string, + async: boolean, }; // eslint-disable-next-line no-unused-vars @@ -29,7 +32,17 @@ export function resolveModuleReference<T>( moduleData: ModuleMetaData, ): ModuleReference<T> { if (bundlerConfig) { - return bundlerConfig[moduleData.id][moduleData.name]; + const resolvedModuleData = bundlerConfig[moduleData.id][moduleData.name]; + if (moduleData.async) { + return { + id: resolvedModuleData.id, + chunks: resolvedModuleData.chunks, + name: resolvedModuleData.name, + async: true, + }; + } else { + return resolvedModuleData; + } } return moduleData; } @@ -39,39 +52,72 @@ export function resolveModuleReference<T>( // in Webpack but unfortunately it's not exposed so we have to // replicate it in user space. null means that it has already loaded. const chunkCache: Map<string, null | Promise<any> | Error> = new Map(); +const asyncModuleCache: Map<string, Thenable<any>> = new Map(); // Start preloading the modules since we might need them soon. // This function doesn't suspend. export function preloadModule<T>(moduleData: ModuleReference<T>): void { const chunks = moduleData.chunks; + const promises = []; for (let i = 0; i < chunks.length; i++) { const chunkId = chunks[i]; const entry = chunkCache.get(chunkId); if (entry === undefined) { const thenable = __webpack_chunk_load__(chunkId); + promises.push(thenable); const resolve = chunkCache.set.bind(chunkCache, chunkId, null); const reject = chunkCache.set.bind(chunkCache, chunkId); thenable.then(resolve, reject); chunkCache.set(chunkId, thenable); } } + if (moduleData.async) { + const modulePromise: any = Promise.all(promises).then(() => { + return __webpack_require__(moduleData.id); + }); + modulePromise.then( + value => { + modulePromise.status = 'fulfilled'; + modulePromise.value = value; + }, + reason => { + modulePromise.status = 'rejected'; + modulePromise.reason = reason; + }, + ); + asyncModuleCache.set(moduleData.id, modulePromise); + } } // Actually require the module or suspend if it's not yet ready. // Increase priority if necessary. export function requireModule<T>(moduleData: ModuleReference<T>): T { - const chunks = moduleData.chunks; - for (let i = 0; i < chunks.length; i++) { - const chunkId = chunks[i]; - const entry = chunkCache.get(chunkId); - if (entry !== null) { - // We assume that preloadModule has been called before. - // So we don't expect to see entry being undefined here, that's an error. - // Let's throw either an error or the Promise. - throw entry; + let moduleExports; + if (moduleData.async) { + // We assume that preloadModule has been called before, which + // should have added something to the module cache. + const promise: any = asyncModuleCache.get(moduleData.id); + if (promise.status === 'fulfilled') { + moduleExports = promise.value; + } else if (promise.status === 'rejected') { + throw promise.reason; + } else { + throw promise; + } + } else { + const chunks = moduleData.chunks; + for (let i = 0; i < chunks.length; i++) { + const chunkId = chunks[i]; + const entry = chunkCache.get(chunkId); + if (entry !== null) { + // We assume that preloadModule has been called before. + // So we don't expect to see entry being undefined here, that's an error. + // Let's throw either an error or the Promise. + throw entry; + } } + moduleExports = __webpack_require__(moduleData.id); } - const moduleExports = __webpack_require__(moduleData.id); if (moduleData.name === '*') { // This is a placeholder value that represents that the caller imported this // as a CommonJS module as is. diff --git a/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js index c8469eeba8068..caeed14a7b7b1 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js @@ -20,12 +20,14 @@ export type ModuleReference<T> = { $$typeof: Symbol, filepath: string, name: string, + async: boolean, }; export type ModuleMetaData = { id: string, chunks: Array<string>, name: string, + async: boolean, }; export type ModuleKey = string; @@ -33,7 +35,12 @@ export type ModuleKey = string; const MODULE_TAG = Symbol.for('react.module.reference'); export function getModuleKey(reference: ModuleReference<any>): ModuleKey { - return reference.filepath + '#' + reference.name; + return ( + reference.filepath + + '#' + + reference.name + + (reference.async ? '#async' : '') + ); } export function isModuleReference(reference: Object): boolean { @@ -44,5 +51,16 @@ export function resolveModuleMetaData<T>( config: BundlerConfig, moduleReference: ModuleReference<T>, ): ModuleMetaData { - return config[moduleReference.filepath][moduleReference.name]; + const resolvedModuleData = + config[moduleReference.filepath][moduleReference.name]; + if (moduleReference.async) { + return { + id: resolvedModuleData.id, + chunks: resolvedModuleData.chunks, + name: resolvedModuleData.name, + async: true, + }; + } else { + return resolvedModuleData; + } } diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js index 9652f03405afa..737a8502801bb 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js @@ -7,7 +7,7 @@ * @flow */ -import acorn from 'acorn'; +import {acorn} from 'acorn'; type ResolveContext = { conditions: Array<string>, diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js index a5f889d3eb15d..0c1ec39aff903 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js @@ -14,6 +14,8 @@ const Module = require('module'); module.exports = function register() { const MODULE_REFERENCE = Symbol.for('react.module.reference'); + const PROMISE_PROTOTYPE = Promise.prototype; + const proxyHandlers = { get: function(target, name, receiver) { switch (name) { @@ -26,6 +28,8 @@ module.exports = function register() { return target.filepath; case 'name': return target.name; + case 'async': + return target.async; // We need to special case this because createElement reads it if we pass this // reference. case 'defaultProps': @@ -39,8 +43,33 @@ module.exports = function register() { // This a placeholder value that tells the client to conditionally use the // whole object or just the default export. name: '', + async: target.async, }; return true; + case 'then': + if (!target.async) { + // If this module is expected to return a Promise (such as an AsyncModule) then + // we should resolve that with a client reference that unwraps the Promise on + // the client. + const then = function then(resolve, reject) { + const moduleReference: {[string]: any} = { + $$typeof: MODULE_REFERENCE, + filepath: target.filepath, + name: '*', // Represents the whole object instead of a particular import. + async: true, + }; + return Promise.resolve( + resolve(new Proxy(moduleReference, proxyHandlers)), + ); + }; + // If this is not used as a Promise but is treated as a reference to a `.then` + // export then we should treat it as a reference to that name. + then.$$typeof = MODULE_REFERENCE; + then.filepath = target.filepath; + // then.name is conveniently already "then" which is the export name we need. + // This will break if it's minified though. + return then; + } } let cachedReference = target[name]; if (!cachedReference) { @@ -48,21 +77,27 @@ module.exports = function register() { $$typeof: MODULE_REFERENCE, filepath: target.filepath, name: name, + async: target.async, }; } return cachedReference; }, + getPrototypeOf(target) { + // Pretend to be a Promise in case anyone asks. + return PROMISE_PROTOTYPE; + }, set: function() { throw new Error('Cannot assign to a client module from a server module.'); }, }; - (require: any).extensions['.client.js'] = function(module, path) { + Module._extensions['.client.js'] = function(module, path) { const moduleId = url.pathToFileURL(path).href; const moduleReference: {[string]: any} = { $$typeof: MODULE_REFERENCE, filepath: moduleId, name: '*', // Represents the whole object instead of a particular import. + async: false, }; module.exports = new Proxy(moduleReference, proxyHandlers); }; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index b68a9b28284bc..4b6995a9edb9d 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -17,14 +17,9 @@ global.TextDecoder = require('util').TextDecoder; // TODO: we can replace this with FlightServer.act(). global.setImmediate = cb => cb(); -let webpackModuleIdx = 0; -let webpackModules = {}; -let webpackMap = {}; -global.__webpack_require__ = function(id) { - return webpackModules[id]; -}; - let act; +let clientExports; +let webpackMap; let Stream; let React; let ReactDOMClient; @@ -35,15 +30,17 @@ let Suspense; describe('ReactFlightDOM', () => { beforeEach(() => { jest.resetModules(); - webpackModules = {}; - webpackMap = {}; act = require('jest-react').act; + const WebpackMock = require('./utils/WebpackMock'); + clientExports = WebpackMock.clientExports; + webpackMap = WebpackMock.webpackMap; + Stream = require('stream'); React = require('react'); + Suspense = React.Suspense; ReactDOMClient = require('react-dom/client'); ReactServerDOMWriter = require('react-server-dom-webpack/writer.node.server'); ReactServerDOMReader = require('react-server-dom-webpack'); - Suspense = React.Suspense; }); function getTestStream() { @@ -64,22 +61,6 @@ describe('ReactFlightDOM', () => { }; } - function moduleReference(moduleExport) { - const idx = webpackModuleIdx++; - webpackModules[idx] = { - d: moduleExport, - }; - webpackMap['path/' + idx] = { - default: { - id: '' + idx, - chunks: [], - name: 'd', - }, - }; - const MODULE_TAG = Symbol.for('react.module.reference'); - return {$$typeof: MODULE_TAG, filepath: 'path/' + idx, name: 'default'}; - } - async function waitForSuspense(fn) { while (true) { try { @@ -256,6 +237,83 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('<p>@div</p>'); }); + it('should unwrap async module references', async () => { + const AsyncModule = Promise.resolve(function AsyncModule({text}) { + return 'Async: ' + text; + }); + + const AsyncModule2 = Promise.resolve({ + exportName: 'Module', + }); + + function Print({response}) { + return <p>{response.readRoot()}</p>; + } + + function App({response}) { + return ( + <Suspense fallback={<h1>Loading...</h1>}> + <Print response={response} /> + </Suspense> + ); + } + + const AsyncModuleRef = await clientExports(AsyncModule); + const AsyncModuleRef2 = await clientExports(AsyncModule2); + + const {writable, readable} = getTestStream(); + const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + <AsyncModuleRef text={AsyncModuleRef2.exportName} />, + webpackMap, + ); + pipe(writable); + const response = ReactServerDOMReader.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(<App response={response} />); + }); + expect(container.innerHTML).toBe('<p>Async: Module</p>'); + }); + + it('should be able to import a name called "then"', async () => { + const thenExports = { + then: function then() { + return 'and then'; + }, + }; + + function Print({response}) { + return <p>{response.readRoot()}</p>; + } + + function App({response}) { + return ( + <Suspense fallback={<h1>Loading...</h1>}> + <Print response={response} /> + </Suspense> + ); + } + + const ThenRef = clientExports(thenExports).then; + + const {writable, readable} = getTestStream(); + const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + <ThenRef />, + webpackMap, + ); + pipe(writable); + const response = ReactServerDOMReader.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(<App response={response} />); + }); + expect(container.innerHTML).toBe('<p>and then</p>'); + }); + it('should progressively reveal server components', async () => { let reportedErrors = []; @@ -345,7 +403,7 @@ describe('ReactFlightDOM', () => { return <div>{games}</div>; } - const MyErrorBoundaryClient = moduleReference(MyErrorBoundary); + const MyErrorBoundaryClient = clientExports(MyErrorBoundary); function ProfileContent() { return ( @@ -470,7 +528,7 @@ describe('ReactFlightDOM', () => { return <input />; } - const InputClient = moduleReference(Input); + const InputClient = clientExports(Input); // Server diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index e2cc4989c8eef..43573af1df141 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -14,13 +14,9 @@ global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStr global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -let webpackModuleIdx = 0; -let webpackModules = {}; -let webpackMap = {}; -global.__webpack_require__ = function(id) { - return webpackModules[id]; -}; - +let clientExports; +let webpackMap; +let webpackModules; let act; let React; let ReactDOMClient; @@ -32,9 +28,11 @@ let Suspense; describe('ReactFlightDOMBrowser', () => { beforeEach(() => { jest.resetModules(); - webpackModules = {}; - webpackMap = {}; act = require('jest-react').act; + const WebpackMock = require('./utils/WebpackMock'); + clientExports = WebpackMock.clientExports; + webpackMap = WebpackMock.webpackMap; + webpackModules = WebpackMock.webpackModules; React = require('react'); ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server.browser'); @@ -43,22 +41,6 @@ describe('ReactFlightDOMBrowser', () => { Suspense = React.Suspense; }); - function moduleReference(moduleExport) { - const idx = webpackModuleIdx++; - webpackModules[idx] = { - d: moduleExport, - }; - webpackMap['path/' + idx] = { - default: { - id: '' + idx, - chunks: [], - name: 'd', - }, - }; - const MODULE_TAG = Symbol.for('react.module.reference'); - return {$$typeof: MODULE_TAG, filepath: 'path/' + idx, name: 'default'}; - } - async function waitForSuspense(fn) { while (true) { try { @@ -249,7 +231,7 @@ describe('ReactFlightDOMBrowser', () => { return <div>{games}</div>; } - const MyErrorBoundaryClient = moduleReference(MyErrorBoundary); + const MyErrorBoundaryClient = clientExports(MyErrorBoundary); function ProfileContent() { return ( @@ -478,19 +460,19 @@ describe('ReactFlightDOMBrowser', () => { } // The Client build may not have the same IDs as the Server bundles for the same // component. - const ClientComponentOnTheClient = moduleReference(ClientComponent); - const ClientComponentOnTheServer = moduleReference(ClientComponent); + const ClientComponentOnTheClient = clientExports(ClientComponent); + const ClientComponentOnTheServer = clientExports(ClientComponent); // In the SSR bundle this module won't exist. We simulate this by deleting it. - const clientId = webpackMap[ClientComponentOnTheClient.filepath].default.id; + const clientId = webpackMap[ClientComponentOnTheClient.filepath]['*'].id; delete webpackModules[clientId]; // Instead, we have to provide a translation from the client meta data to the SSR // meta data. - const ssrMetaData = webpackMap[ClientComponentOnTheServer.filepath].default; + const ssrMetaData = webpackMap[ClientComponentOnTheServer.filepath]['*']; const translationMap = { [clientId]: { - d: ssrMetaData, + '*': ssrMetaData, }, }; diff --git a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js new file mode 100644 index 0000000000000..78f78505b6b2a --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js @@ -0,0 +1,80 @@ +/** + * 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 url = require('url'); +const Module = require('module'); + +let webpackModuleIdx = 0; +const webpackModules = {}; +const webpackMap = {}; +global.__webpack_require__ = function(id) { + return webpackModules[id]; +}; + +const previousLoader = Module._extensions['.client.js']; + +const register = require('react-server-dom-webpack/node-register'); +// Register node loader +register(); + +const nodeLoader = Module._extensions['.client.js']; + +if (previousLoader === nodeLoader) { + throw new Error( + 'Expected the Node loader to register the .client.js extension', + ); +} + +Module._extensions['.client.js'] = previousLoader; + +exports.webpackMap = webpackMap; +exports.webpackModules = webpackModules; + +exports.clientExports = function clientExports(moduleExports) { + const idx = '' + webpackModuleIdx++; + webpackModules[idx] = moduleExports; + const path = url.pathToFileURL(idx).href; + webpackMap[path] = { + '': { + id: idx, + chunks: [], + name: '', + }, + '*': { + id: idx, + chunks: [], + name: '*', + }, + }; + if (typeof moduleExports.then === 'function') { + moduleExports.then(asyncModuleExports => { + for (const name in asyncModuleExports) { + webpackMap[path] = { + [name]: { + id: idx, + chunks: [], + name: name, + }, + }; + } + }); + } + for (const name in moduleExports) { + webpackMap[path] = { + [name]: { + id: idx, + chunks: [], + name: name, + }, + }; + } + const mod = {exports: {}}; + nodeLoader(mod, idx); + return mod.exports; +}; diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index c3ffa8cd6abd8..960800e903c2c 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -25,7 +25,7 @@ import {getTreeId} from './ReactFizzTreeContext'; import {makeId} from './ReactServerFormatConfig'; -import {enableCache} from 'shared/ReactFeatureFlags'; +import {enableCache, enableUseMemoCacheHook} from 'shared/ReactFeatureFlags'; import is from 'shared/objectIs'; type BasicStateAction<S> = (S => S) | S; @@ -537,6 +537,10 @@ function useCacheRefresh(): <T>(?() => T, ?T) => void { return unsupportedRefresh; } +function useMemoCache(size: number): Array<any> { + return new Array(size); +} + function noop(): void {} export const Dispatcher: DispatcherType = { @@ -567,6 +571,9 @@ if (enableCache) { Dispatcher.getCacheForType = getCacheForType; Dispatcher.useCacheRefresh = useCacheRefresh; } +if (enableUseMemoCacheHook) { + Dispatcher.useMemoCache = useMemoCache; +} export let currentResponseState: null | ResponseState = (null: any); export function setCurrentResponseState( diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f2974e0507e1d..7828c808f16c5 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -117,6 +117,7 @@ import { warnAboutDefaultPropsOnFunctionComponents, enableScopeAPI, enableSuspenseAvoidThisFallbackFizz, + enableFloat, } from 'shared/ReactFeatureFlags'; import assign from 'shared/assign'; @@ -200,6 +201,8 @@ export opaque type Request = { clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed. completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show. partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early. + +preamble: Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted before any segment chunks. + +postamble: Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish // onError is called when an error happens anywhere in the tree. It might recover. // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. // Returning null/undefined will cause a defualt error message in production @@ -272,6 +275,8 @@ export function createRequest( clientRenderedBoundaries: [], completedBoundaries: [], partialBoundaries: [], + preamble: [], + postamble: [], onError: onError === undefined ? defaultErrorHandler : onError, onAllReady: onAllReady === undefined ? noop : onAllReady, onShellReady: onShellReady === undefined ? noop : onShellReady, @@ -632,6 +637,7 @@ function renderHostElement( const segment = task.blockedSegment; const children = pushStartInstance( segment.chunks, + request.preamble, type, props, request.responseState, @@ -647,7 +653,7 @@ function renderHostElement( // We expect that errors will fatal the whole task and that we don't need // the correct context. Therefore this is not in a finally. segment.formatContext = prevContext; - pushEndInstance(segment.chunks, type, props); + pushEndInstance(segment.chunks, request.postamble, type, props); segment.lastPushedText = false; popComponentStackInDEV(task); } @@ -2063,20 +2069,31 @@ function flushCompletedQueues( // TODO: Emit preloading. - // TODO: It's kind of unfortunate to keep checking this array after we've already - // emitted the root. + let i; const completedRootSegment = request.completedRootSegment; - if (completedRootSegment !== null && request.pendingRootTasks === 0) { - flushSegment(request, destination, completedRootSegment); - request.completedRootSegment = null; - writeCompletedRoot(destination, request.responseState); + if (completedRootSegment !== null) { + if (request.pendingRootTasks === 0) { + if (enableFloat) { + const preamble = request.preamble; + for (i = 0; i < preamble.length; i++) { + // we expect the preamble to be tiny and will ignore backpressure + writeChunk(destination, preamble[i]); + } + } + + flushSegment(request, destination, completedRootSegment); + request.completedRootSegment = null; + writeCompletedRoot(destination, request.responseState); + } else { + // We haven't flushed the root yet so we don't need to check boundaries further down + return; + } } // We emit client rendering instructions for already emitted boundaries first. // This is so that we can signal to the client to start client rendering them as // soon as possible. const clientRenderedBoundaries = request.clientRenderedBoundaries; - let i; for (i = 0; i < clientRenderedBoundaries.length; i++) { const boundary = clientRenderedBoundaries[i]; if (!flushClientRenderedBoundary(request, destination, boundary)) { @@ -2139,8 +2156,6 @@ function flushCompletedQueues( } largeBoundaries.splice(0, i); } finally { - completeWriting(destination); - flushBuffered(destination); if ( request.allPendingTasks === 0 && request.pingedTasks.length === 0 && @@ -2149,6 +2164,14 @@ function flushCompletedQueues( // We don't need to check any partially completed segments because // either they have pending task or they're complete. ) { + if (enableFloat) { + const postamble = request.postamble; + for (let i = 0; i < postamble.length; i++) { + writeChunk(destination, postamble[i]); + } + } + completeWriting(destination); + flushBuffered(destination); if (__DEV__) { if (request.abortableTasks.size !== 0) { console.error( @@ -2158,6 +2181,9 @@ function flushCompletedQueues( } // We're done. close(destination); + } else { + completeWriting(destination); + flushBuffered(destination); } } } diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index 5b10d8c7a1f61..a9782c712aca2 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -78,6 +78,9 @@ export const Dispatcher: DispatcherType = { useCacheRefresh(): <T>(?() => T, ?T) => void { return unsupportedRefresh; }, + useMemoCache(size: number): Array<any> { + return new Array(size); + }, }; function unsupportedHook(): void { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 814f8f1a4f9d2..1577687c0040e 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -195,6 +195,10 @@ function attemptResolveElement( ); } if (typeof type === 'function') { + if (isModuleReference(type)) { + // This is a reference to a client component. + return [REACT_ELEMENT_TYPE, type, key, props]; + } // This is a server-side component. return type(props); } else if (typeof type === 'string') { @@ -295,6 +299,52 @@ function serializeByRefID(id: number): string { return '@' + id.toString(16); } +function serializeModuleReference( + request: Request, + parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray<ReactModel>, + key: string, + moduleReference: ModuleReference<any>, +): string { + const moduleKey: ModuleKey = getModuleKey(moduleReference); + const writtenModules = request.writtenModules; + const existingId = writtenModules.get(moduleKey); + if (existingId !== undefined) { + if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { + // If we're encoding the "type" of an element, we can refer + // to that by a lazy reference instead of directly since React + // knows how to deal with lazy values. This lets us suspend + // on this component rather than its parent until the code has + // loaded. + return serializeByRefID(existingId); + } + return serializeByValueID(existingId); + } + try { + const moduleMetaData: ModuleMetaData = resolveModuleMetaData( + request.bundlerConfig, + moduleReference, + ); + request.pendingChunks++; + const moduleId = request.nextChunkId++; + emitModuleChunk(request, moduleId, moduleMetaData); + writtenModules.set(moduleKey, moduleId); + if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { + // If we're encoding the "type" of an element, we can refer + // to that by a lazy reference instead of directly since React + // knows how to deal with lazy values. This lets us suspend + // on this component rather than its parent until the code has + // loaded. + return serializeByRefID(moduleId); + } + return serializeByValueID(moduleId); + } catch (x) { + request.pendingChunks++; + const errorId = request.nextChunkId++; + emitErrorChunk(request, errorId, x); + return serializeByValueID(errorId); + } +} + function escapeStringValue(value: string): string { if (value[0] === '$' || value[0] === '@') { // We need to escape $ or @ prefixed strings since we use those to encode @@ -561,45 +611,7 @@ export function resolveModelToJSON( if (typeof value === 'object') { if (isModuleReference(value)) { - const moduleReference: ModuleReference<any> = (value: any); - const moduleKey: ModuleKey = getModuleKey(moduleReference); - const writtenModules = request.writtenModules; - const existingId = writtenModules.get(moduleKey); - if (existingId !== undefined) { - if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { - // If we're encoding the "type" of an element, we can refer - // to that by a lazy reference instead of directly since React - // knows how to deal with lazy values. This lets us suspend - // on this component rather than its parent until the code has - // loaded. - return serializeByRefID(existingId); - } - return serializeByValueID(existingId); - } - try { - const moduleMetaData: ModuleMetaData = resolveModuleMetaData( - request.bundlerConfig, - moduleReference, - ); - request.pendingChunks++; - const moduleId = request.nextChunkId++; - emitModuleChunk(request, moduleId, moduleMetaData); - writtenModules.set(moduleKey, moduleId); - if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { - // If we're encoding the "type" of an element, we can refer - // to that by a lazy reference instead of directly since React - // knows how to deal with lazy values. This lets us suspend - // on this component rather than its parent until the code has - // loaded. - return serializeByRefID(moduleId); - } - return serializeByValueID(moduleId); - } catch (x) { - request.pendingChunks++; - const errorId = request.nextChunkId++; - emitErrorChunk(request, errorId, x); - return serializeByValueID(errorId); - } + return serializeModuleReference(request, parent, key, (value: any)); } else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { const providerKey = ((value: any): ReactProviderType<any>)._context ._globalName; @@ -673,6 +685,9 @@ export function resolveModelToJSON( } if (typeof value === 'function') { + if (isModuleReference(value)) { + return serializeModuleReference(request, parent, key, (value: any)); + } if (/^on[A-Z]/.test(key)) { throw new Error( 'Event handlers cannot be passed to client component props. ' + diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index d75f1f7c9b453..abd6cb5779b49 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -46,6 +46,7 @@ export function waitForSuspense<T>(fn: () => T): Promise<T> { useMutableSource: unsupported, useSyncExternalStore: unsupported, useCacheRefresh: unsupported, + useMemoCache: unsupported, }; // Not using async/await because we don't compile it. return new Promise((resolve, reject) => { diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 0a2795a626516..0bc75a3531681 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -27,6 +27,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, @@ -42,6 +43,7 @@ export { unstable_getCacheSignal, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useMemoCache, useId, useCallback, useContext, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 287c137298815..d60351e263981 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -24,6 +24,7 @@ export { createFactory, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, @@ -35,6 +36,7 @@ export { unstable_getCacheSignal, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useMemoCache, useId, useCallback, useContext, diff --git a/packages/react/index.js b/packages/react/index.js index 084aabb53c6bf..d0628ab003a79 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -49,6 +49,7 @@ export { createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, @@ -63,6 +64,7 @@ export { unstable_getCacheSignal, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useMemoCache, useId, useCallback, useContext, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index e9f80ade06105..24de7511daed5 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -26,6 +26,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, @@ -40,6 +41,7 @@ export { unstable_getCacheSignal, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useMemoCache, useId, useCallback, useContext, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 264c1e1dc56d0..fae7ee56b758e 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -55,6 +55,8 @@ import { useDeferredValue, useId, useCacheRefresh, + use, + useMemoCache, } from './ReactHooks'; import { createElementWithValidation, @@ -127,6 +129,8 @@ export { getCacheForType as unstable_getCacheForType, useCacheRefresh as unstable_useCacheRefresh, REACT_CACHE_TYPE as unstable_Cache, + use as experimental_use, + useMemoCache as unstable_useMemoCache, // enableScopeAPI REACT_SCOPE_TYPE as unstable_Scope, // enableTransitionTracing diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 9dc7a98589e4e..74699ea673e07 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -14,6 +14,7 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, } from 'shared/ReactTypes'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; @@ -204,3 +205,15 @@ export function useCacheRefresh(): <T>(?() => T, ?T) => void { // $FlowFixMe This is unstable, thus optional return dispatcher.useCacheRefresh(); } + +export function use<T>(usable: Usable<T>): T { + const dispatcher = resolveDispatcher(); + // $FlowFixMe This is unstable, thus optional + return dispatcher.use(usable); +} + +export function useMemoCache(size: number): Array<any> { + const dispatcher = resolveDispatcher(); + // $FlowFixMe This is unstable, thus optional + return dispatcher.useMemoCache(size); +} diff --git a/packages/react/src/ReactSharedSubset.experimental.js b/packages/react/src/ReactSharedSubset.experimental.js index 932aa1da6d0cd..4a9ad873c1dc8 100644 --- a/packages/react/src/ReactSharedSubset.experimental.js +++ b/packages/react/src/ReactSharedSubset.experimental.js @@ -19,6 +19,7 @@ export { createElement, createMutableSource as unstable_createMutableSource, createRef, + createServerContext, forwardRef, isValidElement, lazy, diff --git a/packages/react/src/__tests__/ReactElement-test.js b/packages/react/src/__tests__/ReactElement-test.js index 95499947b80fc..0e8a72e2f3888 100644 --- a/packages/react/src/__tests__/ReactElement-test.js +++ b/packages/react/src/__tests__/ReactElement-test.js @@ -9,26 +9,16 @@ 'use strict'; -import {enableSymbolFallbackForWWW} from 'shared/ReactFeatureFlags'; - let React; let ReactDOM; let ReactTestUtils; describe('ReactElement', () => { let ComponentClass; - let originalSymbol; beforeEach(() => { jest.resetModules(); - if (enableSymbolFallbackForWWW) { - // Delete the native Symbol if we have one to ensure we test the - // unpolyfilled environment. - originalSymbol = global.Symbol; - global.Symbol = undefined; - } - React = require('react'); ReactDOM = require('react-dom'); ReactTestUtils = require('react-dom/test-utils'); @@ -41,17 +31,6 @@ describe('ReactElement', () => { }; }); - afterEach(() => { - if (enableSymbolFallbackForWWW) { - global.Symbol = originalSymbol; - } - }); - - // @gate enableSymbolFallbackForWWW - it('uses the fallback value when in an environment without Symbol', () => { - expect((<div />).$$typeof).toBe(0xeac7); - }); - it('returns a complete element according to spec', () => { const element = React.createElement(ComponentClass); expect(element.type).toBe(ComponentClass); @@ -301,42 +280,6 @@ describe('ReactElement', () => { expect(element.type.someStaticMethod()).toBe('someReturnValue'); }); - // NOTE: We're explicitly not using JSX here. This is intended to test - // classic JS without JSX. - // @gate enableSymbolFallbackForWWW - it('identifies valid elements', () => { - class Component extends React.Component { - render() { - return React.createElement('div'); - } - } - - expect(React.isValidElement(React.createElement('div'))).toEqual(true); - expect(React.isValidElement(React.createElement(Component))).toEqual(true); - - expect(React.isValidElement(null)).toEqual(false); - expect(React.isValidElement(true)).toEqual(false); - expect(React.isValidElement({})).toEqual(false); - expect(React.isValidElement('string')).toEqual(false); - if (!__EXPERIMENTAL__) { - let factory; - expect(() => { - factory = React.createFactory('div'); - }).toWarnDev( - 'Warning: React.createFactory() is deprecated and will be removed in a ' + - 'future major release. Consider using JSX or use React.createElement() ' + - 'directly instead.', - {withoutStack: true}, - ); - expect(React.isValidElement(factory)).toEqual(false); - } - expect(React.isValidElement(Component)).toEqual(false); - expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); - - const jsonElement = JSON.stringify(React.createElement('div')); - expect(React.isValidElement(JSON.parse(jsonElement))).toBe(true); - }); - // NOTE: We're explicitly not using JSX here. This is intended to test // classic JS without JSX. it('is indistinguishable from a plain object', () => { @@ -451,94 +394,4 @@ describe('ReactElement', () => { const test = ReactTestUtils.renderIntoDocument(<Test value={+undefined} />); expect(test.props.value).toBeNaN(); }); - - // NOTE: We're explicitly not using JSX here. This is intended to test - // classic JS without JSX. - // @gate !enableSymbolFallbackForWWW - it('identifies elements, but not JSON, if Symbols are supported', () => { - class Component extends React.Component { - render() { - return React.createElement('div'); - } - } - - expect(React.isValidElement(React.createElement('div'))).toEqual(true); - expect(React.isValidElement(React.createElement(Component))).toEqual(true); - - expect(React.isValidElement(null)).toEqual(false); - expect(React.isValidElement(true)).toEqual(false); - expect(React.isValidElement({})).toEqual(false); - expect(React.isValidElement('string')).toEqual(false); - if (!__EXPERIMENTAL__) { - let factory; - expect(() => { - factory = React.createFactory('div'); - }).toWarnDev( - 'Warning: React.createFactory() is deprecated and will be removed in a ' + - 'future major release. Consider using JSX or use React.createElement() ' + - 'directly instead.', - {withoutStack: true}, - ); - expect(React.isValidElement(factory)).toEqual(false); - } - expect(React.isValidElement(Component)).toEqual(false); - expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); - - const jsonElement = JSON.stringify(React.createElement('div')); - expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false); - }); - - // NOTE: We're explicitly not using JSX here. This is intended to test - // classic JS without JSX. - it('identifies elements, but not JSON, if Symbols are supported (with polyfill)', () => { - // Rudimentary polyfill - // Once all jest engines support Symbols natively we can swap this to test - // WITH native Symbols by default. - const REACT_ELEMENT_TYPE = function() {}; // fake Symbol - const OTHER_SYMBOL = function() {}; // another fake Symbol - global.Symbol = function(name) { - return OTHER_SYMBOL; - }; - global.Symbol.for = function(key) { - if (key === 'react.element') { - return REACT_ELEMENT_TYPE; - } - return OTHER_SYMBOL; - }; - - jest.resetModules(); - - React = require('react'); - - class Component extends React.Component { - render() { - return React.createElement('div'); - } - } - - expect(React.isValidElement(React.createElement('div'))).toEqual(true); - expect(React.isValidElement(React.createElement(Component))).toEqual(true); - - expect(React.isValidElement(null)).toEqual(false); - expect(React.isValidElement(true)).toEqual(false); - expect(React.isValidElement({})).toEqual(false); - expect(React.isValidElement('string')).toEqual(false); - if (!__EXPERIMENTAL__) { - let factory; - expect(() => { - factory = React.createFactory('div'); - }).toWarnDev( - 'Warning: React.createFactory() is deprecated and will be removed in a ' + - 'future major release. Consider using JSX or use React.createElement() ' + - 'directly instead.', - {withoutStack: true}, - ); - expect(React.isValidElement(factory)).toEqual(false); - } - expect(React.isValidElement(Component)).toEqual(false); - expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); - - const jsonElement = JSON.stringify(React.createElement('div')); - expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false); - }); }); diff --git a/packages/react/src/__tests__/ReactElementJSX-test.js b/packages/react/src/__tests__/ReactElementJSX-test.js index 16b53923ffb5d..1a4f535a0b2f9 100644 --- a/packages/react/src/__tests__/ReactElementJSX-test.js +++ b/packages/react/src/__tests__/ReactElementJSX-test.js @@ -9,8 +9,6 @@ 'use strict'; -import {enableSymbolFallbackForWWW} from 'shared/ReactFeatureFlags'; - let React; let ReactDOM; let ReactTestUtils; @@ -22,18 +20,9 @@ let JSXDEVRuntime; // A lot of these tests are pulled from ReactElement-test because // this api is meant to be backwards compatible. describe('ReactElement.jsx', () => { - let originalSymbol; - beforeEach(() => { jest.resetModules(); - if (enableSymbolFallbackForWWW) { - // Delete the native Symbol if we have one to ensure we test the - // unpolyfilled environment. - originalSymbol = global.Symbol; - global.Symbol = undefined; - } - React = require('react'); JSXRuntime = require('react/jsx-runtime'); JSXDEVRuntime = require('react/jsx-dev-runtime'); @@ -41,12 +30,6 @@ describe('ReactElement.jsx', () => { ReactTestUtils = require('react-dom/test-utils'); }); - afterEach(() => { - if (enableSymbolFallbackForWWW) { - global.Symbol = originalSymbol; - } - }); - it('allows static methods to be called using the type property', () => { class StaticMethodComponentClass extends React.Component { render() { @@ -59,48 +42,6 @@ describe('ReactElement.jsx', () => { expect(element.type.someStaticMethod()).toBe('someReturnValue'); }); - // @gate enableSymbolFallbackForWWW - it('identifies valid elements', () => { - class Component extends React.Component { - render() { - return JSXRuntime.jsx('div', {}); - } - } - - expect(React.isValidElement(JSXRuntime.jsx('div', {}))).toEqual(true); - expect(React.isValidElement(JSXRuntime.jsx(Component, {}))).toEqual(true); - expect( - React.isValidElement(JSXRuntime.jsx(JSXRuntime.Fragment, {})), - ).toEqual(true); - if (__DEV__) { - expect(React.isValidElement(JSXDEVRuntime.jsxDEV('div', {}))).toEqual( - true, - ); - } - - expect(React.isValidElement(null)).toEqual(false); - expect(React.isValidElement(true)).toEqual(false); - expect(React.isValidElement({})).toEqual(false); - expect(React.isValidElement('string')).toEqual(false); - if (!__EXPERIMENTAL__) { - let factory; - expect(() => { - factory = React.createFactory('div'); - }).toWarnDev( - 'Warning: React.createFactory() is deprecated and will be removed in a ' + - 'future major release. Consider using JSX or use React.createElement() ' + - 'directly instead.', - {withoutStack: true}, - ); - expect(React.isValidElement(factory)).toEqual(false); - } - expect(React.isValidElement(Component)).toEqual(false); - expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); - - const jsonElement = JSON.stringify(JSXRuntime.jsx('div', {})); - expect(React.isValidElement(JSON.parse(jsonElement))).toBe(true); - }); - it('is indistinguishable from a plain object', () => { const element = JSXRuntime.jsx('div', {className: 'foo'}); const object = {}; @@ -294,101 +235,6 @@ describe('ReactElement.jsx', () => { ); }); - // @gate !enableSymbolFallbackForWWW - it('identifies elements, but not JSON, if Symbols are supported', () => { - class Component extends React.Component { - render() { - return JSXRuntime.jsx('div', {}); - } - } - - expect(React.isValidElement(JSXRuntime.jsx('div', {}))).toEqual(true); - expect(React.isValidElement(JSXRuntime.jsx(Component, {}))).toEqual(true); - expect( - React.isValidElement(JSXRuntime.jsx(JSXRuntime.Fragment, {})), - ).toEqual(true); - if (__DEV__) { - expect(React.isValidElement(JSXDEVRuntime.jsxDEV('div', {}))).toEqual( - true, - ); - } - - expect(React.isValidElement(null)).toEqual(false); - expect(React.isValidElement(true)).toEqual(false); - expect(React.isValidElement({})).toEqual(false); - expect(React.isValidElement('string')).toEqual(false); - if (!__EXPERIMENTAL__) { - let factory; - expect(() => { - factory = React.createFactory('div'); - }).toWarnDev( - 'Warning: React.createFactory() is deprecated and will be removed in a ' + - 'future major release. Consider using JSX or use React.createElement() ' + - 'directly instead.', - {withoutStack: true}, - ); - expect(React.isValidElement(factory)).toEqual(false); - } - expect(React.isValidElement(Component)).toEqual(false); - expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); - - const jsonElement = JSON.stringify(JSXRuntime.jsx('div', {})); - expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false); - }); - - it('identifies elements, but not JSON, if Symbols are polyfilled', () => { - // Rudimentary polyfill - // Once all jest engines support Symbols natively we can swap this to test - // WITH native Symbols by default. - const REACT_ELEMENT_TYPE = function() {}; // fake Symbol - const OTHER_SYMBOL = function() {}; // another fake Symbol - global.Symbol = function(name) { - return OTHER_SYMBOL; - }; - global.Symbol.for = function(key) { - if (key === 'react.element') { - return REACT_ELEMENT_TYPE; - } - return OTHER_SYMBOL; - }; - - jest.resetModules(); - - React = require('react'); - JSXRuntime = require('react/jsx-runtime'); - - class Component extends React.Component { - render() { - return JSXRuntime.jsx('div'); - } - } - - expect(React.isValidElement(JSXRuntime.jsx('div', {}))).toEqual(true); - expect(React.isValidElement(JSXRuntime.jsx(Component, {}))).toEqual(true); - - expect(React.isValidElement(null)).toEqual(false); - expect(React.isValidElement(true)).toEqual(false); - expect(React.isValidElement({})).toEqual(false); - expect(React.isValidElement('string')).toEqual(false); - if (!__EXPERIMENTAL__) { - let factory; - expect(() => { - factory = React.createFactory('div'); - }).toWarnDev( - 'Warning: React.createFactory() is deprecated and will be removed in a ' + - 'future major release. Consider using JSX or use React.createElement() ' + - 'directly instead.', - {withoutStack: true}, - ); - expect(React.isValidElement(factory)).toEqual(false); - } - expect(React.isValidElement(Component)).toEqual(false); - expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); - - const jsonElement = JSON.stringify(JSXRuntime.jsx('div', {})); - expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false); - }); - it('should warn when unkeyed children are passed to jsx', () => { const container = document.createElement('div'); diff --git a/packages/react/src/__tests__/ReactStrictMode-test.js b/packages/react/src/__tests__/ReactStrictMode-test.js index 81416f260ae90..8aebe33426a0a 100644 --- a/packages/react/src/__tests__/ReactStrictMode-test.js +++ b/packages/react/src/__tests__/ReactStrictMode-test.js @@ -67,6 +67,7 @@ describe('ReactStrictMode', () => { ); }); + // @gate __DEV__ && !enableStrictEffects it('should invoke precommit lifecycle methods twice', () => { let log = []; let shouldComponentUpdate = false; @@ -107,24 +108,15 @@ describe('ReactStrictMode', () => { container, ); - if (__DEV__) { - expect(log).toEqual([ - 'constructor', - 'constructor', - 'getDerivedStateFromProps', - 'getDerivedStateFromProps', - 'render', - 'render', - 'componentDidMount', - ]); - } else { - expect(log).toEqual([ - 'constructor', - 'getDerivedStateFromProps', - 'render', - 'componentDidMount', - ]); - } + expect(log).toEqual([ + 'constructor', + 'constructor', + 'getDerivedStateFromProps', + 'getDerivedStateFromProps', + 'render', + 'render', + 'componentDidMount', + ]); log = []; shouldComponentUpdate = true; @@ -135,24 +127,15 @@ describe('ReactStrictMode', () => { </React.StrictMode>, container, ); - if (__DEV__) { - expect(log).toEqual([ - 'getDerivedStateFromProps', - 'getDerivedStateFromProps', - 'shouldComponentUpdate', - 'shouldComponentUpdate', - 'render', - 'render', - 'componentDidUpdate', - ]); - } else { - expect(log).toEqual([ - 'getDerivedStateFromProps', - 'shouldComponentUpdate', - 'render', - 'componentDidUpdate', - ]); - } + expect(log).toEqual([ + 'getDerivedStateFromProps', + 'getDerivedStateFromProps', + 'shouldComponentUpdate', + 'shouldComponentUpdate', + 'render', + 'render', + 'componentDidUpdate', + ]); log = []; shouldComponentUpdate = false; @@ -164,19 +147,12 @@ describe('ReactStrictMode', () => { container, ); - if (__DEV__) { - expect(log).toEqual([ - 'getDerivedStateFromProps', - 'getDerivedStateFromProps', - 'shouldComponentUpdate', - 'shouldComponentUpdate', - ]); - } else { - expect(log).toEqual([ - 'getDerivedStateFromProps', - 'shouldComponentUpdate', - ]); - } + expect(log).toEqual([ + 'getDerivedStateFromProps', + 'getDerivedStateFromProps', + 'shouldComponentUpdate', + 'shouldComponentUpdate', + ]); }); it('should invoke setState callbacks twice', () => { diff --git a/packages/scheduler/npm/umd/scheduler.development.js b/packages/scheduler/npm/umd/scheduler.development.js index 21316812d1454..b960dc91132e7 100644 --- a/packages/scheduler/npm/umd/scheduler.development.js +++ b/packages/scheduler/npm/umd/scheduler.development.js @@ -54,13 +54,6 @@ ); } - function unstable_requestYield() { - return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply( - this, - arguments - ); - } - function unstable_runWithPriority() { return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply( this, @@ -123,7 +116,6 @@ unstable_cancelCallback: unstable_cancelCallback, unstable_shouldYield: unstable_shouldYield, unstable_requestPaint: unstable_requestPaint, - unstable_requestYield: unstable_requestYield, unstable_runWithPriority: unstable_runWithPriority, unstable_next: unstable_next, unstable_wrapCallback: unstable_wrapCallback, diff --git a/packages/scheduler/npm/umd/scheduler.production.min.js b/packages/scheduler/npm/umd/scheduler.production.min.js index 41c76570e1ab5..0c2584331b847 100644 --- a/packages/scheduler/npm/umd/scheduler.production.min.js +++ b/packages/scheduler/npm/umd/scheduler.production.min.js @@ -54,13 +54,6 @@ ); } - function unstable_requestYield() { - return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply( - this, - arguments - ); - } - function unstable_runWithPriority() { return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply( this, @@ -117,7 +110,6 @@ unstable_cancelCallback: unstable_cancelCallback, unstable_shouldYield: unstable_shouldYield, unstable_requestPaint: unstable_requestPaint, - unstable_requestYield: unstable_requestYield, unstable_runWithPriority: unstable_runWithPriority, unstable_next: unstable_next, unstable_wrapCallback: unstable_wrapCallback, diff --git a/packages/scheduler/npm/umd/scheduler.profiling.min.js b/packages/scheduler/npm/umd/scheduler.profiling.min.js index 41c76570e1ab5..0c2584331b847 100644 --- a/packages/scheduler/npm/umd/scheduler.profiling.min.js +++ b/packages/scheduler/npm/umd/scheduler.profiling.min.js @@ -54,13 +54,6 @@ ); } - function unstable_requestYield() { - return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply( - this, - arguments - ); - } - function unstable_runWithPriority() { return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply( this, @@ -117,7 +110,6 @@ unstable_cancelCallback: unstable_cancelCallback, unstable_shouldYield: unstable_shouldYield, unstable_requestPaint: unstable_requestPaint, - unstable_requestYield: unstable_requestYield, unstable_runWithPriority: unstable_runWithPriority, unstable_next: unstable_next, unstable_wrapCallback: unstable_wrapCallback, diff --git a/packages/scheduler/src/__tests__/Scheduler-test.js b/packages/scheduler/src/__tests__/Scheduler-test.js index 82cd773629a25..925c64202bb4a 100644 --- a/packages/scheduler/src/__tests__/Scheduler-test.js +++ b/packages/scheduler/src/__tests__/Scheduler-test.js @@ -18,7 +18,6 @@ let performance; let cancelCallback; let scheduleCallback; let requestPaint; -let requestYield; let shouldYield; let NormalPriority; @@ -44,7 +43,6 @@ describe('SchedulerBrowser', () => { scheduleCallback = Scheduler.unstable_scheduleCallback; NormalPriority = Scheduler.unstable_NormalPriority; requestPaint = Scheduler.unstable_requestPaint; - requestYield = Scheduler.unstable_requestYield; shouldYield = Scheduler.unstable_shouldYield; }); @@ -480,19 +478,13 @@ describe('SchedulerBrowser', () => { ]); }); - it('requestYield forces a yield immediately', () => { + it('yielding continues in a new task regardless of how much time is remaining', () => { scheduleCallback(NormalPriority, () => { runtime.log('Original Task'); runtime.log('shouldYield: ' + shouldYield()); - runtime.log('requestYield'); - requestYield(); - runtime.log('shouldYield: ' + shouldYield()); + runtime.log('Return a continuation'); return () => { runtime.log('Continuation Task'); - runtime.log('shouldYield: ' + shouldYield()); - runtime.log('Advance time past frame deadline'); - runtime.advanceTime(10000); - runtime.log('shouldYield: ' + shouldYield()); }; }); runtime.assertLog(['Post Message']); @@ -501,27 +493,20 @@ describe('SchedulerBrowser', () => { runtime.assertLog([ 'Message Event', 'Original Task', + // Immediately before returning a continuation, `shouldYield` returns + // false, which means there must be time remaining in the frame. 'shouldYield: false', - 'requestYield', - // Immediately after calling requestYield, shouldYield starts - // returning true, even though no time has elapsed in the frame - 'shouldYield: true', + 'Return a continuation', - // The continuation should be scheduled in a separate macrotask. + // The continuation should be scheduled in a separate macrotask even + // though there's time remaining. 'Post Message', ]); // No time has elapsed expect(performance.now()).toBe(0); - // Subsequent tasks work as normal runtime.fireMessageEvent(); - runtime.assertLog([ - 'Message Event', - 'Continuation Task', - 'shouldYield: false', - 'Advance time past frame deadline', - 'shouldYield: true', - ]); + runtime.assertLog(['Message Event', 'Continuation Task']); }); }); diff --git a/packages/scheduler/src/__tests__/SchedulerMock-test.js b/packages/scheduler/src/__tests__/SchedulerMock-test.js index 8b01ec3c0e319..aa17e88ececda 100644 --- a/packages/scheduler/src/__tests__/SchedulerMock-test.js +++ b/packages/scheduler/src/__tests__/SchedulerMock-test.js @@ -726,37 +726,53 @@ describe('Scheduler', () => { expect(Scheduler).toFlushWithoutYielding(); }); - it('requestYield forces a yield immediately', () => { + it('toFlushUntilNextPaint stops if a continuation is returned', () => { scheduleCallback(NormalPriority, () => { Scheduler.unstable_yieldValue('Original Task'); - Scheduler.unstable_yieldValue( - 'shouldYield: ' + Scheduler.unstable_shouldYield(), - ); - Scheduler.unstable_yieldValue('requestYield'); - Scheduler.unstable_requestYield(); - Scheduler.unstable_yieldValue( - 'shouldYield: ' + Scheduler.unstable_shouldYield(), - ); + Scheduler.unstable_yieldValue('shouldYield: ' + shouldYield()); + Scheduler.unstable_yieldValue('Return a continuation'); return () => { Scheduler.unstable_yieldValue('Continuation Task'); - Scheduler.unstable_yieldValue( - 'shouldYield: ' + Scheduler.unstable_shouldYield(), - ); - Scheduler.unstable_yieldValue('Advance time past frame deadline'); - Scheduler.unstable_yieldValue( - 'shouldYield: ' + Scheduler.unstable_shouldYield(), - ); }; }); - // The continuation should be scheduled in a separate macrotask. expect(Scheduler).toFlushUntilNextPaint([ 'Original Task', + // Immediately before returning a continuation, `shouldYield` returns + // false, which means there must be time remaining in the frame. 'shouldYield: false', - 'requestYield', - // Immediately after calling requestYield, shouldYield starts - // returning true - 'shouldYield: true', + 'Return a continuation', + + // The continuation should not flush yet. + ]); + + // No time has elapsed + expect(Scheduler.unstable_now()).toBe(0); + + // Continue the task + expect(Scheduler).toFlushAndYield(['Continuation Task']); + }); + + it("toFlushAndYield keeps flushing even if there's a continuation", () => { + scheduleCallback(NormalPriority, () => { + Scheduler.unstable_yieldValue('Original Task'); + Scheduler.unstable_yieldValue('shouldYield: ' + shouldYield()); + Scheduler.unstable_yieldValue('Return a continuation'); + return () => { + Scheduler.unstable_yieldValue('Continuation Task'); + }; + }); + + expect(Scheduler).toFlushAndYield([ + 'Original Task', + // Immediately before returning a continuation, `shouldYield` returns + // false, which means there must be time remaining in the frame. + 'shouldYield: false', + 'Return a continuation', + + // The continuation should flush immediately, even though the task + // yielded a continuation. + 'Continuation Task', ]); }); }); diff --git a/packages/scheduler/src/__tests__/SchedulerPostTask-test.js b/packages/scheduler/src/__tests__/SchedulerPostTask-test.js index 2dbb96dd8993b..cc1fc8b63f674 100644 --- a/packages/scheduler/src/__tests__/SchedulerPostTask-test.js +++ b/packages/scheduler/src/__tests__/SchedulerPostTask-test.js @@ -23,7 +23,6 @@ let UserBlockingPriority; let LowPriority; let IdlePriority; let shouldYield; -let requestYield; // The Scheduler postTask implementation uses a new postTask browser API to // schedule work on the main thread. This test suite mocks all browser methods @@ -47,7 +46,6 @@ describe('SchedulerPostTask', () => { LowPriority = Scheduler.unstable_LowPriority; IdlePriority = Scheduler.unstable_IdlePriority; shouldYield = Scheduler.unstable_shouldYield; - requestYield = Scheduler.unstable_requestYield; }); afterEach(() => { @@ -301,19 +299,13 @@ describe('SchedulerPostTask', () => { ]); }); - it('requestYield forces a yield immediately', () => { + it('yielding continues in a new task regardless of how much time is remaining', () => { scheduleCallback(NormalPriority, () => { runtime.log('Original Task'); runtime.log('shouldYield: ' + shouldYield()); - runtime.log('requestYield'); - requestYield(); - runtime.log('shouldYield: ' + shouldYield()); + runtime.log('Return a continuation'); return () => { runtime.log('Continuation Task'); - runtime.log('shouldYield: ' + shouldYield()); - runtime.log('Advance time past frame deadline'); - runtime.advanceTime(10000); - runtime.log('shouldYield: ' + shouldYield()); }; }); runtime.assertLog(['Post Task 0 [user-visible]']); @@ -322,27 +314,20 @@ describe('SchedulerPostTask', () => { runtime.assertLog([ 'Task 0 Fired', 'Original Task', + // Immediately before returning a continuation, `shouldYield` returns + // false, which means there must be time remaining in the frame. 'shouldYield: false', - 'requestYield', - // Immediately after calling requestYield, shouldYield starts - // returning true, even though no time has elapsed in the frame - 'shouldYield: true', + 'Return a continuation', - // The continuation should be scheduled in a separate macrotask. + // The continuation should be scheduled in a separate macrotask even + // though there's time remaining. 'Post Task 1 [user-visible]', ]); // No time has elapsed expect(performance.now()).toBe(0); - // Subsequent tasks work as normal runtime.flushTasks(); - runtime.assertLog([ - 'Task 1 Fired', - 'Continuation Task', - 'shouldYield: false', - 'Advance time past frame deadline', - 'shouldYield: true', - ]); + runtime.assertLog(['Task 1 Fired', 'Continuation Task']); }); }); diff --git a/packages/scheduler/src/forks/Scheduler.js b/packages/scheduler/src/forks/Scheduler.js index ea440375a35a6..3350c798fabe1 100644 --- a/packages/scheduler/src/forks/Scheduler.js +++ b/packages/scheduler/src/forks/Scheduler.js @@ -212,10 +212,14 @@ function workLoop(hasTimeRemaining, initialTime) { const continuationCallback = callback(didUserCallbackTimeout); currentTime = getCurrentTime(); if (typeof continuationCallback === 'function') { + // If a continuation is returned, immediately yield to the main thread + // regardless of how much time is left in the current time slice. currentTask.callback = continuationCallback; if (enableProfiling) { markTaskYield(currentTask, currentTime); } + advanceTimers(currentTime); + return true; } else { if (enableProfiling) { markTaskCompleted(currentTask, currentTime); @@ -224,8 +228,8 @@ function workLoop(hasTimeRemaining, initialTime) { if (currentTask === peek(taskQueue)) { pop(taskQueue); } + advanceTimers(currentTime); } - advanceTimers(currentTime); } else { pop(taskQueue); } @@ -495,11 +499,6 @@ function requestPaint() { // Since we yield every frame regardless, `requestPaint` has no effect. } -function requestYield() { - // Force a yield at the next opportunity. - startTime = -99999; -} - function forceFrameRate(fps) { if (fps < 0 || fps > 125) { // Using console['error'] to evade Babel and ESLint @@ -617,7 +616,6 @@ export { unstable_getCurrentPriorityLevel, shouldYieldToHost as unstable_shouldYield, requestPaint as unstable_requestPaint, - requestYield as unstable_requestYield, unstable_continueExecution, unstable_pauseExecution, unstable_getFirstCallbackNode, diff --git a/packages/scheduler/src/forks/SchedulerMock.js b/packages/scheduler/src/forks/SchedulerMock.js index 6898be823904b..68dac0a7e6876 100644 --- a/packages/scheduler/src/forks/SchedulerMock.js +++ b/packages/scheduler/src/forks/SchedulerMock.js @@ -195,10 +195,22 @@ function workLoop(hasTimeRemaining, initialTime) { const continuationCallback = callback(didUserCallbackTimeout); currentTime = getCurrentTime(); if (typeof continuationCallback === 'function') { + // If a continuation is returned, immediately yield to the main thread + // regardless of how much time is left in the current time slice. currentTask.callback = continuationCallback; if (enableProfiling) { markTaskYield(currentTask, currentTime); } + advanceTimers(currentTime); + + if (shouldYieldForPaint) { + needsPaint = true; + return true; + } else { + // If `shouldYieldForPaint` is false, we keep flushing synchronously + // without yielding to the main thread. This is the behavior of the + // `toFlushAndYield` and `toFlushAndYieldThrough` testing helpers . + } } else { if (enableProfiling) { markTaskCompleted(currentTask, currentTime); @@ -207,8 +219,8 @@ function workLoop(hasTimeRemaining, initialTime) { if (currentTask === peek(taskQueue)) { pop(taskQueue); } + advanceTimers(currentTime); } - advanceTimers(currentTime); } else { pop(taskQueue); } @@ -505,6 +517,11 @@ function unstable_flushUntilNextPaint(): void { isFlushing = false; } } + return false; +} + +function unstable_hasPendingWork(): boolean { + return scheduledCallback !== null; } function unstable_flushExpired() { @@ -608,11 +625,6 @@ function requestPaint() { needsPaint = true; } -function requestYield() { - // Force a yield at the next opportunity. - shouldYieldForPaint = needsPaint = true; -} - export { ImmediatePriority as unstable_ImmediatePriority, UserBlockingPriority as unstable_UserBlockingPriority, @@ -627,7 +639,6 @@ export { unstable_getCurrentPriorityLevel, shouldYieldToHost as unstable_shouldYield, requestPaint as unstable_requestPaint, - requestYield as unstable_requestYield, unstable_continueExecution, unstable_pauseExecution, unstable_getFirstCallbackNode, @@ -638,6 +649,7 @@ export { unstable_flushExpired, unstable_clearYields, unstable_flushUntilNextPaint, + unstable_hasPendingWork, unstable_flushAll, unstable_yieldValue, unstable_advanceTime, diff --git a/packages/scheduler/src/forks/SchedulerPostTask.js b/packages/scheduler/src/forks/SchedulerPostTask.js index 4e51a5873430e..c07f7f03819c3 100644 --- a/packages/scheduler/src/forks/SchedulerPostTask.js +++ b/packages/scheduler/src/forks/SchedulerPostTask.js @@ -67,11 +67,6 @@ export function unstable_requestPaint() { // Since we yield every frame regardless, `requestPaint` has no effect. } -export function unstable_requestYield() { - // Force a yield at the next opportunity. - deadline = -99999; -} - type SchedulerCallback<T> = ( didTimeout_DEPRECATED: boolean, ) => diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 33d233f92e4ef..ba7fcb61a613c 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -24,9 +24,6 @@ export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; // like migrating internal callers or performance testing. // ----------------------------------------------------------------------------- -// This is blocked on adding a symbol polyfill to www. -export const enableSymbolFallbackForWWW = false; - // This rolled out to 10% public in www, so we should be able to land, but some // internal tests need to be updated. The open source behavior is correct. export const skipUnmountedBoundaries = true; @@ -116,6 +113,13 @@ export const enableCPUSuspense = __EXPERIMENTAL__; // aggressiveness. export const deletedTreeCleanUpLevel = 3; +export const enableFloat = __EXPERIMENTAL__; +export const enableUseHook = __EXPERIMENTAL__; + +// Enables unstable_useMemoCache hook, intended as a compilation target for +// auto-memoization. +export const enableUseMemoCacheHook = __EXPERIMENTAL__; + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/packages/shared/ReactSymbols.www.js b/packages/shared/ReactSymbols.www.js deleted file mode 100644 index 523cc1436dfc2..0000000000000 --- a/packages/shared/ReactSymbols.www.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * 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 - */ - -// ATTENTION -// When adding new symbols to this file, -// Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols' - -import {enableSymbolFallbackForWWW} from './ReactFeatureFlags'; - -const usePolyfill = - enableSymbolFallbackForWWW && (typeof Symbol !== 'function' || !Symbol.for); - -// The Symbol used to tag the ReactElement-like types. -export const REACT_ELEMENT_TYPE = usePolyfill - ? 0xeac7 - : Symbol.for('react.element'); -export const REACT_PORTAL_TYPE = usePolyfill - ? 0xeaca - : Symbol.for('react.portal'); -export const REACT_FRAGMENT_TYPE = usePolyfill - ? 0xeacb - : Symbol.for('react.fragment'); -export const REACT_STRICT_MODE_TYPE = usePolyfill - ? 0xeacc - : Symbol.for('react.strict_mode'); -export const REACT_PROFILER_TYPE = usePolyfill - ? 0xead2 - : Symbol.for('react.profiler'); -export const REACT_PROVIDER_TYPE = usePolyfill - ? 0xeacd - : Symbol.for('react.provider'); -export const REACT_CONTEXT_TYPE = usePolyfill - ? 0xeace - : Symbol.for('react.context'); -export const REACT_SERVER_CONTEXT_TYPE = usePolyfill - ? 0xeae6 - : Symbol.for('react.server_context'); -export const REACT_FORWARD_REF_TYPE = usePolyfill - ? 0xead0 - : Symbol.for('react.forward_ref'); -export const REACT_SUSPENSE_TYPE = usePolyfill - ? 0xead1 - : Symbol.for('react.suspense'); -export const REACT_SUSPENSE_LIST_TYPE = usePolyfill - ? 0xead8 - : Symbol.for('react.suspense_list'); -export const REACT_MEMO_TYPE = usePolyfill ? 0xead3 : Symbol.for('react.memo'); -export const REACT_LAZY_TYPE = usePolyfill ? 0xead4 : Symbol.for('react.lazy'); -export const REACT_SCOPE_TYPE = usePolyfill - ? 0xead7 - : Symbol.for('react.scope'); -export const REACT_DEBUG_TRACING_MODE_TYPE = usePolyfill - ? 0xeae1 - : Symbol.for('react.debug_trace_mode'); -export const REACT_OFFSCREEN_TYPE = usePolyfill - ? 0xeae2 - : Symbol.for('react.offscreen'); -export const REACT_LEGACY_HIDDEN_TYPE = usePolyfill - ? 0xeae3 - : Symbol.for('react.legacy_hidden'); -export const REACT_CACHE_TYPE = usePolyfill - ? 0xeae4 - : Symbol.for('react.cache'); -export const REACT_TRACING_MARKER_TYPE = usePolyfill - ? 0xeae5 - : Symbol.for('react.tracing_marker'); -export const REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED = usePolyfill - ? 0xeae7 - : Symbol.for('react.default_value'); -const MAYBE_ITERATOR_SYMBOL = usePolyfill - ? typeof Symbol === 'function' && Symbol.iterator - : Symbol.iterator; - -const FAUX_ITERATOR_SYMBOL = '@@iterator'; - -export function getIteratorFn(maybeIterable: ?any): ?() => ?Iterator<*> { - if (maybeIterable === null || typeof maybeIterable !== 'object') { - return null; - } - const maybeIterator = - (MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL]) || - maybeIterable[FAUX_ITERATOR_SYMBOL]; - if (typeof maybeIterator === 'function') { - return maybeIterator; - } - return null; -} diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 17aa509e89eb9..7dacd489e4b8a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -174,13 +174,36 @@ export interface Wakeable { // The subset of a Promise that React APIs rely on. This resolves a value. // This doesn't require a return value neither from the handler nor the // then function. -export interface Thenable<+R> { - then<U>( - onFulfill: (value: R) => void | Thenable<U> | U, - onReject: (error: mixed) => void | Thenable<U> | U, - ): void | Thenable<U>; +interface ThenableImpl<T> { + then( + onFulfill: (value: T) => mixed, + onReject: (error: mixed) => mixed, + ): void | Wakeable; +} +interface UntrackedThenable<T> extends ThenableImpl<T> { + status?: void; +} + +export interface PendingThenable<T> extends ThenableImpl<T> { + status: 'pending'; +} + +export interface FulfilledThenable<T> extends ThenableImpl<T> { + status: 'fulfilled'; + value: T; } +export interface RejectedThenable<T> extends ThenableImpl<T> { + status: 'rejected'; + reason: mixed; +} + +export type Thenable<T> = + | UntrackedThenable<T> + | PendingThenable<T> + | FulfilledThenable<T> + | RejectedThenable<T>; + export type OffscreenMode = | 'hidden' | 'unstable-defer-without-hiding' @@ -189,3 +212,6 @@ export type OffscreenMode = export type StartTransitionOptions = { name?: string, }; + +// TODO: Add Context support +export type Usable<T> = Thenable<T>; diff --git a/packages/shared/__tests__/ReactSymbols-test.internal.js b/packages/shared/__tests__/ReactSymbols-test.internal.js index 4b6a2d0bc61c0..53618ba3fe4fa 100644 --- a/packages/shared/__tests__/ReactSymbols-test.internal.js +++ b/packages/shared/__tests__/ReactSymbols-test.internal.js @@ -26,20 +26,4 @@ describe('ReactSymbols', () => { it('Symbol values should be unique', () => { expectToBeUnique(Object.entries(require('shared/ReactSymbols'))); }); - - // @gate enableSymbolFallbackForWWW - it('numeric values should be unique', () => { - const originalSymbolFor = global.Symbol.for; - global.Symbol.for = null; - try { - const entries = Object.entries(require('shared/ReactSymbols.www')).filter( - // REACT_ASYNC_MODE_TYPE and REACT_CONCURRENT_MODE_TYPE have the same numeric value - // for legacy backwards compatibility - ([key]) => key !== 'REACT_ASYNC_MODE_TYPE', - ); - expectToBeUnique(entries); - } finally { - global.Symbol.for = originalSymbolFor; - } - }); }); diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 0e07c9f67d994..76f19551875ba 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -50,6 +50,8 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = false; @@ -79,8 +81,8 @@ export const enableServerContext = false; export const enableUseMutableSource = true; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 933d914ce5a62..6de6388896c23 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -40,6 +40,8 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = false; @@ -68,8 +70,8 @@ export const enableServerContext = false; export const enableUseMutableSource = false; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index b218c53470bda..1ac2ff4f2acf0 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -40,6 +40,8 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; @@ -68,8 +70,8 @@ export const enableServerContext = false; export const enableUseMutableSource = false; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index b76a1b8506d13..8b3d4e230d438 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -49,6 +49,8 @@ export const deferRenderPhaseUpdateToNextBatch = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableStrictEffects = false; @@ -66,8 +68,8 @@ export const enableServerContext = false; export const enableUseMutableSource = false; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 63ba329f01a90..e414784563f3c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -40,6 +40,8 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; @@ -70,8 +72,8 @@ export const enableServerContext = false; export const enableUseMutableSource = true; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 9e5a9107ab2b1..0d719ca3523d2 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -40,6 +40,8 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; @@ -68,8 +70,8 @@ export const enableServerContext = false; export const enableUseMutableSource = false; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index a4c3e1ba32678..6d2cd32d9f86b 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -40,6 +40,8 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableUseHook = false; +export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; @@ -69,8 +71,8 @@ export const enableServerContext = false; export const enableUseMutableSource = true; export const enableTransitionTracing = false; -export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 7a89e41ad54f2..520b102b7d3fc 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -28,7 +28,6 @@ export const consoleManagedByDevToolsDuringStrictMode = __VARIANT__; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = __VARIANT__; export const enableClientRenderFallbackOnTextMismatch = __VARIANT__; export const enableTransitionTracing = __VARIANT__; -export const enableSymbolFallbackForWWW = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. // @@ -60,5 +59,6 @@ export const disableNativeComponentFrames = false; export const createRootStrictEffectsByDefault = false; export const enableStrictEffects = false; export const allowConcurrentByDefault = true; +export const enableFloat = false; // You probably *don't* want to add more hardcoded ones. // Instead, try to add them above with the __VARIANT__ value. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 50d078d3f0fd9..2c59dde3a11b6 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -52,6 +52,9 @@ export const enableUpdaterTracking = __PROFILE__; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableFloat = false; +export const enableUseHook = true; +export const enableUseMemoCacheHook = true; // Logs additional User Timing API marks for use with an experimental profiling tool. export const enableSchedulingProfiler = @@ -107,7 +110,6 @@ export const enableUseMutableSource = true; export const enableCustomElementPropertySupport = __EXPERIMENTAL__; -export const enableSymbolFallbackForWWW = true; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/isValidElementType.js b/packages/shared/isValidElementType.js index 598a53666f60c..87424982afc11 100644 --- a/packages/shared/isValidElementType.js +++ b/packages/shared/isValidElementType.js @@ -31,19 +31,9 @@ import { enableTransitionTracing, enableDebugTracing, enableLegacyHidden, - enableSymbolFallbackForWWW, } from './ReactFeatureFlags'; -let REACT_MODULE_REFERENCE; -if (enableSymbolFallbackForWWW) { - if (typeof Symbol === 'function') { - REACT_MODULE_REFERENCE = Symbol.for('react.module.reference'); - } else { - REACT_MODULE_REFERENCE = 0; - } -} else { - REACT_MODULE_REFERENCE = Symbol.for('react.module.reference'); -} +const REACT_MODULE_REFERENCE: Symbol = Symbol.for('react.module.reference'); export default function isValidElementType(type: mixed) { if (typeof type === 'string' || typeof type === 'function') { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 010afa06e70f3..423b30de52dd2 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -420,5 +420,8 @@ "432": "The render was aborted by the server without a reason.", "433": "useId can only be used while React is rendering", "434": "`dangerouslySetInnerHTML` does not make sense on <title>.", - "435": "Unexpected Suspense handler tag (%s). This is a bug in React." + "435": "Unexpected Suspense handler tag (%s). This is a bug in React.", + "436": "Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of \"%s\".", + "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", + "438": "An unsupported type was passed to use(): %s" } diff --git a/scripts/jest/setupTests.www.js b/scripts/jest/setupTests.www.js index e80448db4e078..c0058cb6e0849 100644 --- a/scripts/jest/setupTests.www.js +++ b/scripts/jest/setupTests.www.js @@ -19,10 +19,6 @@ jest.mock('shared/ReactFeatureFlags', () => { return wwwFlags; }); -jest.mock('shared/ReactSymbols', () => { - return jest.requireActual('shared/ReactSymbols.www'); -}); - jest.mock('scheduler/src/SchedulerFeatureFlags', () => { const schedulerSrcPath = process.cwd() + '/packages/scheduler'; jest.mock( diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 68407ef01183d..f93225d9d15da 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -408,7 +408,8 @@ const bundles = [ { bundleTypes: [NODE_ES2015], moduleType: RENDERER_UTILS, - entry: 'react-server-dom-webpack/node-register', + entry: 'react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js', + name: 'react-server-dom-webpack-node-register', global: 'ReactFlightWebpackNodeRegister', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 5c3abebc5e495..d45c41a74268d 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -138,17 +138,6 @@ const forks = Object.freeze({ return null; }, - './packages/shared/ReactSymbols.js': bundleType => { - switch (bundleType) { - case FB_WWW_DEV: - case FB_WWW_PROD: - case FB_WWW_PROFILING: - return './packages/shared/ReactSymbols.www.js'; - default: - return './packages/shared/ReactSymbols.js'; - } - }, - './packages/scheduler/index.js': (bundleType, entry, dependencies) => { switch (bundleType) { case UMD_DEV: