diff --git a/.circleci/config.yml b/.circleci/config.yml index cc165921bf3bc..7d273d01637f1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -48,40 +48,6 @@ parameters: default: '' jobs: - yarn_build: - docker: *docker - environment: *environment - parallelism: 40 - steps: - - checkout - - setup_node_modules - - run: yarn build --ci=circleci - - persist_to_workspace: - root: . - paths: - - build - - process_artifacts_combined: - docker: *docker - environment: *environment - steps: - - checkout - - attach_workspace: - at: . - - setup_node_modules - - run: echo "<< pipeline.git.revision >>" >> build/COMMIT_SHA - - run: | - mkdir -p ./build/__test_utils__ - node ./scripts/print-warnings/print-warnings.js > build/__test_utils__/ReactAllWarnings.js - # Compress build directory into a single tarball for easy download - - run: tar -zcvf ./build.tgz ./build - # TODO: Migrate scripts to use `build` directory instead of `build2` - - run: cp ./build.tgz ./build2.tgz - - store_artifacts: - path: ./build2.tgz - - store_artifacts: - path: ./build.tgz - publish_prerelease: parameters: commit_sha: @@ -105,19 +71,6 @@ jobs: scripts/release/publish.js --ci --tags << parameters.dist_tag >> workflows: - - build_and_test: - unless: << pipeline.parameters.prerelease_commit_sha >> - jobs: - - yarn_build: - filters: - branches: - ignore: - - builds/facebook-www - - process_artifacts_combined: - requires: - - yarn_build - # Used to publish a prerelease manually via the command line publish_preleases: when: << pipeline.parameters.prerelease_commit_sha >> diff --git a/.github/workflows/runtime_commit_artifacts.yml b/.github/workflows/runtime_commit_artifacts.yml index c665f9b1bd40e..c0a6874119094 100644 --- a/.github/workflows/runtime_commit_artifacts.yml +++ b/.github/workflows/runtime_commit_artifacts.yml @@ -1,8 +1,11 @@ -name: (Runtime) Commit Artifacts for Meta WWW and fbsource +name: (Runtime) Commit Artifacts for Meta WWW and fbsource V2 on: - push: - branches: [main, meta-www, meta-fbsource] + workflow_run: + workflows: ["(Runtime) Build and Test"] + types: [completed] + branches: + - main env: TZ: /usr/share/zoneinfo/America/Los_Angeles @@ -49,102 +52,27 @@ jobs: run: | echo "www_branch_count=$(git ls-remote --heads origin "refs/heads/meta-www" | wc -l)" >> "$GITHUB_OUTPUT" echo "fbsource_branch_count=$(git ls-remote --heads origin "refs/heads/meta-fbsource" | wc -l)" >> "$GITHUB_OUTPUT" - - name: Download and unzip artifacts - uses: actions/github-script@v6 - env: - CIRCLECI_TOKEN: ${{secrets.CIRCLECI_TOKEN_DIFFTRAIN}} + - uses: actions/setup-node@v4 with: - script: | - // TODO: Move this to a script file. - const cp = require('child_process'); - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - function execHelper(command, options, streamStdout = false) { - return new Promise((resolve, reject) => { - const proc = cp.exec( - command, - options, - (error, stdout) => (error ? reject(error) : resolve(stdout.trim())), - ); - if (streamStdout) { - proc.stdout.pipe(process.stdout); - } - }); - } - - let artifactsUrl = null; - // This is a temporary, dirty hack to avoid needing a GitHub auth token in the circleci - // workflow to notify this GitHub action. Sorry! - let iter = 0; - spinloop: while (iter < 15) { - const res = await github.rest.repos.listCommitStatusesForRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: context.sha - }); - for (const status of res.data) { - if (/process_artifacts_combined/.test(status.context)) { - switch (status.state) { - case 'pending': { - console.log(`${status.context} is still pending`); - break; - } - case 'failure': - case 'error': { - throw new Error(`${status.context} has failed or errored`); - } - case 'success': { - // The status does not include a build ID, but we can extract it - // from the URL. I couldn't find a better way to do this. - const ciBuildId = /\/facebook\/react\/([0-9]+)/.exec( - status.target_url, - )[1]; - if (Number.parseInt(ciBuildId, 10) + '' === ciBuildId) { - artifactsUrl = - `https://circleci.com/api/v1.1/project/github/facebook/react/${ciBuildId}/artifacts`; - console.log(`Found artifactsUrl: ${artifactsUrl}`); - break spinloop; - } else { - throw new Error(`${ciBuildId} isn't a number`); - } - break; - } - default: { - throw new Error(`Unhandled status state: ${status.state}`); - break; - } - } - } - } - iter++; - console.log("Sleeping for 60s..."); - await sleep(60_000); - } - if (artifactsUrl != null) { - const {CIRCLECI_TOKEN} = process.env; - const res = await fetch(artifactsUrl, { - headers: { - 'Circle-Token': CIRCLECI_TOKEN - } - }); - const data = await res.json(); - if (!Array.isArray(data) && data.message != null) { - throw `CircleCI returned: ${data.message}`; - } - for (const artifact of data) { - if (artifact.path === 'build.tgz') { - console.log(`Downloading and unzipping ${artifact.url}`); - await execHelper( - `curl -L ${artifact.url} -H "Circle-Token: ${CIRCLECI_TOKEN}" | tar -xvz` - ); - } - } - } else { - process.exitCode = 1; - } + node-version: 18.20.1 + cache: yarn + cache-dependency-path: yarn.lock + - name: Restore cached node_modules + uses: actions/cache@v4 + id: node_modules + with: + path: "**/node_modules" + key: ${{ runner.arch }}-${{ runner.os }}-modules-${{ hashFiles('yarn.lock', 'scripts/release/yarn.lock') }} + - run: yarn install --frozen-lockfile + name: yarn install (react) + - run: yarn install --frozen-lockfile + name: yarn install (scripts/release) + working-directory: scripts/release + - name: Download artifacts for base revision + run: | + GH_TOKEN=${{ github.token }} scripts/release/download-experimental-build-ghaction.js --commit=${{ github.event.workflow_run.head_sha }} + - name: Display structure of build + run: ls -R build - name: Strip @license from eslint plugin and react-refresh run: | sed -i -e 's/ @license React*//' \ @@ -199,9 +127,9 @@ jobs: ls -R ./compiled-rn - name: Add REVISION files run: | - echo ${{ github.sha }} >> ./compiled/facebook-www/REVISION + echo ${{ github.event.workflow_run.head_sha }} >> ./compiled/facebook-www/REVISION cp ./compiled/facebook-www/REVISION ./compiled/facebook-www/REVISION_TRANSFORMS - echo ${{ github.sha }} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION + echo ${{ github.event.workflow_run.head_sha}} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION - name: "Get current version string" id: get_current_version run: | @@ -214,11 +142,11 @@ jobs: echo "current_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" echo "current_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" echo "current_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: compiled path: compiled/ - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: compiled-rn path: compiled-rn/ @@ -233,7 +161,7 @@ jobs: ref: builds/facebook-www - name: Ensure clean directory run: rm -rf compiled - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: compiled path: compiled/ @@ -298,12 +226,12 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | - ${{ github.event.head_commit.message }} + ${{ github.event.workflow_run.head_commit.message }} - DiffTrain build for [${{ github.sha }}](https://github.com/facebook/react/commit/${{ github.sha }}) + DiffTrain build for [${{ github.event.workflow_run.head_sha }}](https://github.com/facebook/react/commit/${{ github.event.workflow_run.head_sha }}) branch: builds/facebook-www - commit_user_name: ${{ github.actor }} - commit_user_email: ${{ github.actor }}@users.noreply.github.com + commit_user_name: ${{ github.event.workflow_run.triggering_actor.login }} + commit_user_email: ${{ github.event.workflow_run.triggering_actor.email || format('{0}@users.noreply.github.com', github.event.workflow_run.triggering_actor.login) }} create_branch: true commit_fbsource_artifacts: @@ -316,7 +244,7 @@ jobs: ref: builds/facebook-fbsource - name: Ensure clean directory run: rm -rf compiled-rn - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: compiled-rn path: compiled-rn/ @@ -365,7 +293,7 @@ jobs: git add . - name: Signing files if: steps.check_should_commit.outputs.should_commit == 'true' - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | // TODO: Move this to a script file. @@ -456,10 +384,10 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | - ${{ github.event.head_commit.message }} + ${{ github.event.workflow_run.head_commit.message }} - DiffTrain build for commit https://github.com/facebook/react/commit/${{ github.sha }}. + DiffTrain build for commit https://github.com/facebook/react/commit/${{ github.event.workflow_run.head_sha }}. branch: builds/facebook-fbsource - commit_user_name: ${{ github.actor }} - commit_user_email: ${{ github.actor }}@users.noreply.github.com + commit_user_name: ${{ github.event.workflow_run.triggering_actor.login }} + commit_user_email: ${{ github.event.workflow_run.triggering_actor.email || format('{0}@users.noreply.github.com', github.event.workflow_run.triggering_actor.login) }} create_branch: true diff --git a/.github/workflows/runtime_commit_artifacts_v2.yml b/.github/workflows/runtime_commit_artifacts_v2.yml deleted file mode 100644 index adbb3761138cf..0000000000000 --- a/.github/workflows/runtime_commit_artifacts_v2.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: (Runtime) Commit Artifacts for Meta WWW and fbsource V2 - -on: - workflow_run: - workflows: ["(Runtime) Build and Test"] - types: [completed] - branches: - - main - -env: - TZ: /usr/share/zoneinfo/America/Los_Angeles - -jobs: - download_artifacts: - runs-on: ubuntu-latest - outputs: - www_branch_count: ${{ steps.check_branches.outputs.www_branch_count }} - fbsource_branch_count: ${{ steps.check_branches.outputs.fbsource_branch_count }} - last_version_classic: ${{ steps.get_last_version_www.outputs.last_version_classic }} - last_version_modern: ${{ steps.get_last_version_www.outputs.last_version_modern }} - last_version_rn: ${{ steps.get_last_version_rn.outputs.last_version_rn }} - current_version_classic: ${{ steps.get_current_version.outputs.current_version_classic }} - current_version_modern: ${{ steps.get_current_version.outputs.current_version_modern }} - current_version_rn: ${{ steps.get_current_version.outputs.current_version_rn }} - steps: - - uses: actions/checkout@v4 - with: - ref: builds/facebook-www - - name: "Get last version string for www" - id: get_last_version_www - run: | - # Empty checks only needed for backwards compatibility,can remove later. - VERSION_CLASSIC=$( [ -f ./compiled/facebook-www/VERSION_CLASSIC ] && cat ./compiled/facebook-www/VERSION_CLASSIC || echo '' ) - VERSION_MODERN=$( [ -f ./compiled/facebook-www/VERSION_MODERN ] && cat ./compiled/facebook-www/VERSION_MODERN || echo '' ) - echo "Last classic version is $VERSION_CLASSIC" - echo "Last modern version is $VERSION_MODERN" - echo "last_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" - echo "last_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v4 - with: - ref: builds/facebook-fbsource - - name: "Get last version string for rn" - id: get_last_version_rn - run: | - # Empty checks only needed for backwards compatibility,can remove later. - VERSION_NATIVE_FB=$( [ -f ./compiled-rn/VERSION_NATIVE_FB ] && cat ./compiled-rn/VERSION_NATIVE_FB || echo '' ) - echo "Last rn version is $VERSION_NATIVE_FB" - echo "last_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v4 - - name: "Check branches" - id: check_branches - run: | - echo "www_branch_count=$(git ls-remote --heads origin "refs/heads/meta-www" | wc -l)" >> "$GITHUB_OUTPUT" - echo "fbsource_branch_count=$(git ls-remote --heads origin "refs/heads/meta-fbsource" | wc -l)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 - with: - node-version: 18.20.1 - cache: yarn - cache-dependency-path: yarn.lock - - name: Restore cached node_modules - uses: actions/cache@v4 - id: node_modules - with: - path: "**/node_modules" - key: ${{ runner.arch }}-${{ runner.os }}-modules-${{ hashFiles('yarn.lock', 'scripts/release/yarn.lock') }} - - run: yarn install --frozen-lockfile - - run: yarn install --frozen-lockfile - working-directory: scripts/release - - name: Download artifacts for base revision - run: | - git fetch origin main - GH_TOKEN=${{ github.token }} scripts/release/download-experimental-build-ghaction.js --commit=$(git rev-parse origin/main) - - name: Display structure of build - run: ls -R build - - name: Strip @license from eslint plugin and react-refresh - run: | - sed -i -e 's/ @license React*//' \ - build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \ - build/oss-experimental/react-refresh/cjs/react-refresh-babel.development.js - - name: Move relevant files for React in www into compiled - run: | - # Move the facebook-www folder into compiled - mkdir ./compiled - mv build/facebook-www ./compiled - - # Move ReactAllWarnings.js to facebook-www - mkdir ./compiled/facebook-www/__test_utils__ - mv build/__test_utils__/ReactAllWarnings.js ./compiled/facebook-www/__test_utils__/ReactAllWarnings.js - - # Move eslint-plugin-react-hooks into facebook-www - mv build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \ - ./compiled/facebook-www/eslint-plugin-react-hooks.js - - # Move unstable_server-external-runtime.js into facebook-www - mv build/oss-experimental/react-dom/unstable_server-external-runtime.js \ - ./compiled/facebook-www/unstable_server-external-runtime.js - - # Move react-refresh-babel.development.js into babel-plugin-react-refresh - mkdir ./compiled/babel-plugin-react-refresh - mv build/oss-experimental/react-refresh/cjs/react-refresh-babel.development.js \ - ./compiled/babel-plugin-react-refresh/index.js - - ls -R ./compiled - - name: Move relevant files for React in fbsource into compiled-rn - run: | - BASE_FOLDER='compiled-rn/facebook-fbsource/xplat/js' - mkdir -p ${BASE_FOLDER}/react-native-github/Libraries/Renderer/ - mkdir -p ${BASE_FOLDER}/RKJSModules/vendor/react/{scheduler,react,react-is,react-test-renderer}/ - - # Move React Native renderer - mv build/react-native/implementations/ $BASE_FOLDER/react-native-github/Libraries/Renderer/ - mv build/react-native/shims/ $BASE_FOLDER/react-native-github/Libraries/Renderer/ - mv build/facebook-react-native/scheduler/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/scheduler/ - mv build/facebook-react-native/react/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react/ - mv build/facebook-react-native/react-is/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-is/ - mv build/facebook-react-native/react-test-renderer/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-test-renderer/ - - # Delete OSS renderer. OSS renderer is synced through internal script. - RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/ - rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js - rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js - - # Move React Native version file - mv build/facebook-react-native/VERSION_NATIVE_FB ./compiled-rn/VERSION_NATIVE_FB - - ls -R ./compiled-rn - - name: Add REVISION files - run: | - echo ${{ github.sha }} >> ./compiled/facebook-www/REVISION - cp ./compiled/facebook-www/REVISION ./compiled/facebook-www/REVISION_TRANSFORMS - echo ${{ github.sha }} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION - - name: "Get current version string" - id: get_current_version - run: | - VERSION_CLASSIC=$(cat ./compiled/facebook-www/VERSION_CLASSIC) - VERSION_MODERN=$(cat ./compiled/facebook-www/VERSION_MODERN) - VERSION_NATIVE_FB=$(cat ./compiled-rn/VERSION_NATIVE_FB) - echo "Current classic version is $VERSION_CLASSIC" - echo "Current modern version is $VERSION_MODERN" - echo "Current rn version is $VERSION_NATIVE_FB" - echo "current_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" - echo "current_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" - echo "current_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v3 - with: - name: compiled - path: compiled/ - - uses: actions/upload-artifact@v3 - with: - name: compiled-rn - path: compiled-rn/ diff --git a/compiler/package.json b/compiler/package.json index e9f98b0642799..82207d4333a13 100644 --- a/compiler/package.json +++ b/compiler/package.json @@ -12,7 +12,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/facebook/react-forget.git" + "url": "git+https://github.com/facebook/react.git" }, "scripts": { "copyright": "node scripts/copyright.js", diff --git a/compiler/packages/babel-plugin-react-compiler/package.json b/compiler/packages/babel-plugin-react-compiler/package.json index b27512f94c3b0..c8d9f8b17bc96 100644 --- a/compiler/packages/babel-plugin-react-compiler/package.json +++ b/compiler/packages/babel-plugin-react-compiler/package.json @@ -63,5 +63,10 @@ "@babel/core": "7.2.0", "@babel/generator": "7.2.0", "@babel/traverse": "7.7.4" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/react.git", + "directory": "compiler/packages/babel-plugin-react-compiler" } } diff --git a/compiler/packages/eslint-plugin-react-compiler/package.json b/compiler/packages/eslint-plugin-react-compiler/package.json index f394d0ab1d3fd..ebd28555b0867 100644 --- a/compiler/packages/eslint-plugin-react-compiler/package.json +++ b/compiler/packages/eslint-plugin-react-compiler/package.json @@ -35,5 +35,10 @@ "peerDependencies": { "eslint": ">=7" }, + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/react.git", + "directory": "compiler/packages/eslint-plugin-react-compiler" + }, "license": "MIT" } diff --git a/compiler/packages/make-read-only-util/package.json b/compiler/packages/make-read-only-util/package.json index fd79714bb4b41..212d934669d5d 100644 --- a/compiler/packages/make-read-only-util/package.json +++ b/compiler/packages/make-read-only-util/package.json @@ -19,5 +19,10 @@ "jest": "^28.1.3", "ts-jest": "^28.0.7", "ts-node": "^10.9.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/react.git", + "directory": "compiler/packages/make-read-only-util" } } diff --git a/compiler/packages/react-compiler-healthcheck/package.json b/compiler/packages/react-compiler-healthcheck/package.json index f0ed77d428eb9..673e18d8ebb34 100644 --- a/compiler/packages/react-compiler-healthcheck/package.json +++ b/compiler/packages/react-compiler-healthcheck/package.json @@ -23,5 +23,10 @@ "engines": { "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" }, - "license": "MIT" + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/react.git", + "directory": "compiler/packages/react-compiler-healthcheck" + } } diff --git a/compiler/packages/react-compiler-runtime/package.json b/compiler/packages/react-compiler-runtime/package.json index 08f7473a5f5c7..9c356a8d274fb 100644 --- a/compiler/packages/react-compiler-runtime/package.json +++ b/compiler/packages/react-compiler-runtime/package.json @@ -14,5 +14,10 @@ "scripts": { "build": "rimraf dist && rollup --config --bundleConfigAsCjs", "test": "echo 'no tests'" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/react.git", + "directory": "compiler/packages/react-compiler-runtime" } } diff --git a/compiler/packages/snap/package.json b/compiler/packages/snap/package.json index f7b05ebe53079..5cc4eca5003d4 100644 --- a/compiler/packages/snap/package.json +++ b/compiler/packages/snap/package.json @@ -15,7 +15,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/facebook/react-forget.git" + "url": "git+https://github.com/facebook/react.git", + "directory": "compiler/packages/snap" }, "dependencies": { "@babel/code-frame": "^7.22.5", diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 69827f2bf05f3..edbef05e259d1 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -37,6 +37,7 @@ import { REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; import hasOwnProperty from 'shared/hasOwnProperty'; +import type {ContextDependencyWithSelect} from '../../react-reconciler/src/ReactInternalTypes'; type CurrentDispatcherRef = typeof ReactSharedInternals; @@ -155,7 +156,10 @@ function getPrimitiveStackCache(): Map> { let currentFiber: null | Fiber = null; let currentHook: null | Hook = null; -let currentContextDependency: null | ContextDependency = null; +let currentContextDependency: + | null + | ContextDependency + | ContextDependencyWithSelect = null; function nextHook(): null | Hook { const hook = currentHook; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js index f3a1b37103f48..5dcc3c9c3b93d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -335,4 +335,67 @@ describe('ReactDOMFizzStatic', () => { }); expect(getVisibleChildren(container)).toEqual(undefined); }); + + // @gate experimental + it('will prerender Suspense fallbacks before children', async () => { + const values = []; + function Indirection({children}) { + values.push(children); + return children; + } + + function App() { + return ( +
+ + outer loading... +
+ }> + + first inner loading... + + }> +
+ hello world +
+
+ + second inner loading... + + }> +
+ goodbye world +
+
+ + + ); + } + + const result = await ReactDOMFizzStatic.prerenderToNodeStream(); + + expect(values).toEqual([ + 'outer loading...', + 'first inner loading...', + 'second inner loading...', + 'hello world', + 'goodbye world', + ]); + + await act(async () => { + result.prelude.pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+
hello world
+
goodbye world
+
, + ); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js index e0adcfece0781..ade755bdffea1 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js @@ -111,9 +111,7 @@ describe('ReactDOMFizzStaticNode', () => { const result = await resultPromise; const prelude = await readContent(result.prelude); - expect(prelude).toMatchInlineSnapshot( - `"
Done
"`, - ); + expect(prelude).toMatchInlineSnapshot(`"
Done
"`); }); // @gate experimental diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 5e9d9085457c1..78c9c8a9e05ff 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -47,6 +47,7 @@ import { enableUseDeferredValueInitialArg, disableLegacyMode, enableNoCloningMemoCache, + enableContextProfiling, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -81,7 +82,11 @@ import { ContinuousEventPriority, higherEventPriority, } from './ReactEventPriorities'; -import {readContext, checkIfContextChanged} from './ReactFiberNewContext'; +import { + readContext, + readContextAndCompare, + checkIfContextChanged, +} from './ReactFiberNewContext'; import {HostRoot, CacheComponent, HostComponent} from './ReactWorkTags'; import { LayoutStatic as LayoutStaticEffect, @@ -1053,6 +1058,16 @@ function updateWorkInProgressHook(): Hook { return workInProgressHook; } +function unstable_useContextWithBailout( + context: ReactContext, + select: (T => Array) | null, +): T { + if (select === null) { + return readContext(context); + } + return readContextAndCompare(context, select); +} + // NOTE: defining two versions of this function to avoid size impact when this feature is disabled. // Previously this function was inlined, the additional `memoCache` property makes it not inlined. let createFunctionComponentUpdateQueue: () => FunctionComponentUpdateQueue; @@ -3689,6 +3704,10 @@ if (enableAsyncActions) { if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError; } +if (enableContextProfiling) { + (ContextOnlyDispatcher: Dispatcher).unstable_useContextWithBailout = + throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -3728,6 +3747,10 @@ if (enableAsyncActions) { if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic; } +if (enableContextProfiling) { + (HooksDispatcherOnMount: Dispatcher).unstable_useContextWithBailout = + unstable_useContextWithBailout; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -3767,6 +3790,10 @@ if (enableAsyncActions) { if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic; } +if (enableContextProfiling) { + (HooksDispatcherOnUpdate: Dispatcher).unstable_useContextWithBailout = + unstable_useContextWithBailout; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -3806,6 +3833,10 @@ if (enableAsyncActions) { if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic; } +if (enableContextProfiling) { + (HooksDispatcherOnRerender: Dispatcher).unstable_useContextWithBailout = + unstable_useContextWithBailout; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -4019,6 +4050,17 @@ if (__DEV__) { return mountOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (HooksDispatcherOnMountInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + mountHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -4200,6 +4242,17 @@ if (__DEV__) { return mountOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + updateHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4380,6 +4433,17 @@ if (__DEV__) { return updateOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + updateHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4560,6 +4624,17 @@ if (__DEV__) { return rerenderOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + updateHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -4766,6 +4841,18 @@ if (__DEV__) { return mountOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4972,6 +5059,18 @@ if (__DEV__) { return updateOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -5178,4 +5277,16 @@ if (__DEV__) { return rerenderOptimistic(passthrough, reducer); }; } + if (enableContextProfiling) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).unstable_useContextWithBailout = + function ( + context: ReactContext, + select: (T => Array) | null, + ): T { + currentHookNameInDev = 'useContext'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return unstable_useContextWithBailout(context, select); + }; + } } diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index c19e056b0a3cd..cc908167e1e4f 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -12,6 +12,7 @@ import type { Fiber, ContextDependency, Dependencies, + ContextDependencyWithSelect, } from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack'; import type {Lanes} from './ReactFiberLane'; @@ -51,6 +52,8 @@ import { getHostTransitionProvider, HostTransitionContext, } from './ReactFiberHostContext'; +import isArray from '../../shared/isArray'; +import {enableContextProfiling} from '../../shared/ReactFeatureFlags'; const valueCursor: StackCursor = createCursor(null); @@ -70,7 +73,10 @@ if (__DEV__) { } let currentlyRenderingFiber: Fiber | null = null; -let lastContextDependency: ContextDependency | null = null; +let lastContextDependency: + | ContextDependency + | ContextDependencyWithSelect + | null = null; let lastFullyObservedContext: ReactContext | null = null; let isDisallowedContextReadInDEV: boolean = false; @@ -400,8 +406,24 @@ function propagateContextChanges( findContext: for (let i = 0; i < contexts.length; i++) { const context: ReactContext = contexts[i]; // Check if the context matches. - // TODO: Compare selected values to bail out early. if (dependency.context === context) { + if (enableContextProfiling) { + const select = dependency.select; + if (select != null && dependency.lastSelectedValue != null) { + const newValue = isPrimaryRenderer + ? dependency.context._currentValue + : dependency.context._currentValue2; + if ( + !checkIfSelectedContextValuesChanged( + dependency.lastSelectedValue, + select(newValue), + ) + ) { + // Compared value hasn't changed. Bail out early. + continue findContext; + } + } + } // Match! Schedule an update on this fiber. // In the lazy implementation, don't mark a dirty flag on the @@ -641,6 +663,29 @@ function propagateParentContextChanges( workInProgress.flags |= DidPropagateContext; } +function checkIfSelectedContextValuesChanged( + oldComparedValue: Array, + newComparedValue: Array, +): boolean { + // We have an implicit contract that compare functions must return arrays. + // This allows us to compare multiple values in the same context access + // since compiling to additional hook calls regresses perf. + if (isArray(oldComparedValue) && isArray(newComparedValue)) { + if (oldComparedValue.length !== newComparedValue.length) { + return true; + } + + for (let i = 0; i < oldComparedValue.length; i++) { + if (!is(newComparedValue[i], oldComparedValue[i])) { + return true; + } + } + } else { + throw new Error('Compared context values must be arrays'); + } + return false; +} + export function checkIfContextChanged( currentDependencies: Dependencies, ): boolean { @@ -659,8 +704,23 @@ export function checkIfContextChanged( ? context._currentValue : context._currentValue2; const oldValue = dependency.memoizedValue; - if (!is(newValue, oldValue)) { - return true; + if ( + enableContextProfiling && + dependency.select != null && + dependency.lastSelectedValue != null + ) { + if ( + checkIfSelectedContextValuesChanged( + dependency.lastSelectedValue, + dependency.select(newValue), + ) + ) { + return true; + } + } else { + if (!is(newValue, oldValue)) { + return true; + } } dependency = dependency.next; } @@ -694,6 +754,21 @@ export function prepareToReadContext( } } +export function readContextAndCompare( + context: ReactContext, + select: C => Array, +): C { + if (!(enableLazyContextPropagation && enableContextProfiling)) { + throw new Error('Not implemented.'); + } + + return readContextForConsumer_withSelect( + currentlyRenderingFiber, + context, + select, + ); +} + export function readContext(context: ReactContext): T { if (__DEV__) { // This warning would fire if you read context inside a Hook like useMemo. @@ -721,10 +796,57 @@ export function readContextDuringReconciliation( return readContextForConsumer(consumer, context); } -function readContextForConsumer( +function readContextForConsumer_withSelect( consumer: Fiber | null, - context: ReactContext, -): T { + context: ReactContext, + select: C => Array, +): C { + const value = isPrimaryRenderer + ? context._currentValue + : context._currentValue2; + + if (lastFullyObservedContext === context) { + // Nothing to do. We already observe everything in this context. + } else { + const contextItem = { + context: ((context: any): ReactContext), + memoizedValue: value, + next: null, + select: ((select: any): (context: mixed) => Array), + lastSelectedValue: select(value), + }; + + if (lastContextDependency === null) { + if (consumer === null) { + throw new Error( + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + } + + // This is the first dependency for this component. Create a new list. + lastContextDependency = contextItem; + consumer.dependencies = { + lanes: NoLanes, + firstContext: contextItem, + }; + if (enableLazyContextPropagation) { + consumer.flags |= NeedsPropagation; + } + } else { + // Append a new context item. + lastContextDependency = lastContextDependency.next = contextItem; + } + } + return value; +} + +function readContextForConsumer( + consumer: Fiber | null, + context: ReactContext, +): C { const value = isPrimaryRenderer ? context._currentValue : context._currentValue2; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index beee88de2549b..4549253ba79b6 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -61,16 +61,26 @@ export type HookType = | 'useFormState' | 'useActionState'; -export type ContextDependency = { - context: ReactContext, - next: ContextDependency | null, - memoizedValue: T, - ... +export type ContextDependency = { + context: ReactContext, + next: ContextDependency | ContextDependencyWithSelect | null, + memoizedValue: C, +}; + +export type ContextDependencyWithSelect = { + context: ReactContext, + next: ContextDependency | ContextDependencyWithSelect | null, + memoizedValue: C, + select: C => Array, + lastSelectedValue: ?Array, }; export type Dependencies = { lanes: Lanes, - firstContext: ContextDependency | null, + firstContext: + | ContextDependency + | ContextDependencyWithSelect + | null, ... }; @@ -384,6 +394,10 @@ export type Dispatcher = { initialArg: I, init?: (I) => S, ): [S, Dispatch], + unstable_useContextWithBailout?: ( + context: ReactContext, + select: (T => Array) | null, + ) => T, useContext(context: ReactContext): T, useRef(initialValue: T): {current: T}, useEffect( diff --git a/packages/react-reconciler/src/__tests__/ReactContextWithBailout-test.js b/packages/react-reconciler/src/__tests__/ReactContextWithBailout-test.js new file mode 100644 index 0000000000000..c510206148b16 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactContextWithBailout-test.js @@ -0,0 +1,217 @@ +let React; +let ReactNoop; +let Scheduler; +let act; +let assertLog; +let useState; +let useContext; +let unstable_useContextWithBailout; + +describe('ReactContextWithBailout', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + const testUtils = require('internal-test-utils'); + act = testUtils.act; + assertLog = testUtils.assertLog; + useState = React.useState; + useContext = React.useContext; + unstable_useContextWithBailout = React.unstable_useContextWithBailout; + }); + + function Text({text}) { + Scheduler.log(text); + return text; + } + + // @gate enableLazyContextPropagation && enableContextProfiling + test('unstable_useContextWithBailout basic usage', async () => { + const Context = React.createContext(); + + let setContext; + function App() { + const [context, _setContext] = useState({a: 'A0', b: 'B0', c: 'C0'}); + setContext = _setContext; + return ( + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ( + <> + A: , B: , C: , AB: + + ); + }); + + function A() { + const {a} = unstable_useContextWithBailout(Context, context => [ + context.a, + ]); + return ; + } + + function B() { + const {b} = unstable_useContextWithBailout(Context, context => [ + context.b, + ]); + return ; + } + + function C() { + const {c} = unstable_useContextWithBailout(Context, context => [ + context.c, + ]); + return ; + } + + function AB() { + const {a, b} = unstable_useContextWithBailout(Context, context => [ + context.a, + context.b, + ]); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + assertLog(['A0', 'B0', 'C0', 'A0B0']); + expect(root).toMatchRenderedOutput('A: A0, B: B0, C: C0, AB: A0B0'); + + // Update a. Only the A and AB consumer should re-render. + await act(async () => { + setContext({a: 'A1', c: 'C0', b: 'B0'}); + }); + assertLog(['A1', 'A1B0']); + expect(root).toMatchRenderedOutput('A: A1, B: B0, C: C0, AB: A1B0'); + + // Update b. Only the B and AB consumer should re-render. + await act(async () => { + setContext({a: 'A1', b: 'B1', c: 'C0'}); + }); + assertLog(['B1', 'A1B1']); + expect(root).toMatchRenderedOutput('A: A1, B: B1, C: C0, AB: A1B1'); + + // Update c. Only the C consumer should re-render. + await act(async () => { + setContext({a: 'A1', b: 'B1', c: 'C1'}); + }); + assertLog(['C1']); + expect(root).toMatchRenderedOutput('A: A1, B: B1, C: C1, AB: A1B1'); + }); + + // @gate enableLazyContextPropagation && enableContextProfiling + test('unstable_useContextWithBailout and useContext subscribing to same context in same component', async () => { + const Context = React.createContext(); + + let setContext; + function App() { + const [context, _setContext] = useState({a: 0, b: 0, unrelated: 0}); + setContext = _setContext; + return ( + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ; + }); + + function Child() { + const {a} = unstable_useContextWithBailout(Context, context => [ + context.a, + ]); + const context = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + assertLog(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update an unrelated field that isn't used by the component. The context + // attempts to bail out, but the normal context forces an update. + await act(async () => { + setContext({a: 0, b: 0, unrelated: 1}); + }); + assertLog(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + }); + + // @gate enableLazyContextPropagation && enableContextProfiling + test('unstable_useContextWithBailout and useContext subscribing to different contexts in same component', async () => { + const ContextA = React.createContext(); + const ContextB = React.createContext(); + + let setContextA; + let setContextB; + function App() { + const [a, _setContextA] = useState({a: 0, unrelated: 0}); + const [b, _setContextB] = useState(0); + setContextA = _setContextA; + setContextB = _setContextB; + return ( + + + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ; + }); + + function Child() { + const {a} = unstable_useContextWithBailout(ContextA, context => [ + context.a, + ]); + const b = useContext(ContextB); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + assertLog(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update a field in A that isn't part of the compared context. It should + // bail out. + await act(async () => { + setContextA({a: 0, unrelated: 1}); + }); + assertLog([]); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Now update the same a field again, but this time, also update a different + // context in the same batch. The other context prevents a bail out. + await act(async () => { + setContextA({a: 0, unrelated: 1}); + setContextB(1); + }); + assertLog(['A: 0, B: 1']); + expect(root).toMatchRenderedOutput('A: 0, B: 1'); + }); +}); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a1ca5dbaa2838..85fa3c65d41d5 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1138,125 +1138,172 @@ function renderSuspenseBoundary( // no parent segment so there's nothing to wait on. contentRootSegment.parentFlushed = true; - // Currently this is running synchronously. We could instead schedule this to pingedTasks. - // I suspect that there might be some efficiency benefits from not creating the suspended task - // and instead just using the stack if possible. - // TODO: Call this directly instead of messing with saving and restoring contexts. + if (request.trackedPostpones !== null) { + // This is a prerender. In this mode we want to render the fallback synchronously and schedule + // the content to render later. This is the opposite of what we do during a normal render + // where we try to skip rendering the fallback if the content itself can render synchronously + const trackedPostpones = request.trackedPostpones; - // We can reuse the current context and task to render the content immediately without - // context switching. We just need to temporarily switch which boundary and which segment - // we're writing to. If something suspends, it'll spawn new suspended task with that context. - task.blockedBoundary = newBoundary; - task.hoistableState = newBoundary.contentState; - task.blockedSegment = contentRootSegment; - task.keyPath = keyPath; + const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]]; + const fallbackReplayNode: ReplayNode = [ + fallbackKeyPath[1], + fallbackKeyPath[2], + ([]: Array), + null, + ]; + trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode); + // We are rendering the fallback before the boundary content so we keep track of + // the fallback replay node until we determine if the primary content suspends + newBoundary.trackedFallbackNode = fallbackReplayNode; - try { - // We use the safe form because we don't handle suspending here. Only error handling. - renderNode(request, task, content, -1); - pushSegmentFinale( - contentRootSegment.chunks, - request.renderState, - contentRootSegment.lastPushedText, - contentRootSegment.textEmbedded, - ); - contentRootSegment.status = COMPLETED; - queueCompletedSegment(newBoundary, contentRootSegment); - if (newBoundary.pendingTasks === 0 && newBoundary.status === PENDING) { - // This must have been the last segment we were waiting on. This boundary is now complete. - // Therefore we won't need the fallback. We early return so that we don't have to create - // the fallback. - newBoundary.status = COMPLETED; - return; + task.blockedSegment = boundarySegment; + task.keyPath = fallbackKeyPath; + try { + renderNode(request, task, fallback, -1); + pushSegmentFinale( + boundarySegment.chunks, + request.renderState, + boundarySegment.lastPushedText, + boundarySegment.textEmbedded, + ); + boundarySegment.status = COMPLETED; + } finally { + task.blockedSegment = parentSegment; + task.keyPath = prevKeyPath; } - } catch (error: mixed) { - contentRootSegment.status = ERRORED; - newBoundary.status = CLIENT_RENDERED; - const thrownInfo = getThrownInfo(task.componentStack); - let errorDigest; - if ( - enablePostpone && - typeof error === 'object' && - error !== null && - error.$$typeof === REACT_POSTPONE_TYPE - ) { - const postponeInstance: Postpone = (error: any); - logPostpone( - request, - postponeInstance.message, - thrownInfo, - __DEV__ && enableOwnerStacks ? task.debugTask : null, + + // We create a suspended task for the primary content because we want to allow + // sibling fallbacks to be rendered first. + const suspendedPrimaryTask = createRenderTask( + request, + null, + content, + -1, + newBoundary, + contentRootSegment, + newBoundary.contentState, + task.abortSet, + keyPath, + task.formatContext, + task.context, + task.treeContext, + task.componentStack, + task.isFallback, + !disableLegacyContext ? task.legacyContext : emptyContextObject, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + pushComponentStack(suspendedPrimaryTask); + request.pingedTasks.push(suspendedPrimaryTask); + } else { + // This is a normal render. We will attempt to synchronously render the boundary content + // If it is successful we will elide the fallback task but if it suspends or errors we schedule + // the fallback to render. Unlike with prerenders we attempt to deprioritize the fallback render + + // Currently this is running synchronously. We could instead schedule this to pingedTasks. + // I suspect that there might be some efficiency benefits from not creating the suspended task + // and instead just using the stack if possible. + // TODO: Call this directly instead of messing with saving and restoring contexts. + + // We can reuse the current context and task to render the content immediately without + // context switching. We just need to temporarily switch which boundary and which segment + // we're writing to. If something suspends, it'll spawn new suspended task with that context. + task.blockedBoundary = newBoundary; + task.hoistableState = newBoundary.contentState; + task.blockedSegment = contentRootSegment; + task.keyPath = keyPath; + + try { + // We use the safe form because we don't handle suspending here. Only error handling. + renderNode(request, task, content, -1); + pushSegmentFinale( + contentRootSegment.chunks, + request.renderState, + contentRootSegment.lastPushedText, + contentRootSegment.textEmbedded, ); - // TODO: Figure out a better signal than a magic digest value. - errorDigest = 'POSTPONE'; - } else { - errorDigest = logRecoverableError( - request, + contentRootSegment.status = COMPLETED; + queueCompletedSegment(newBoundary, contentRootSegment); + if (newBoundary.pendingTasks === 0 && newBoundary.status === PENDING) { + // This must have been the last segment we were waiting on. This boundary is now complete. + // Therefore we won't need the fallback. We early return so that we don't have to create + // the fallback. + newBoundary.status = COMPLETED; + return; + } + } catch (error: mixed) { + contentRootSegment.status = ERRORED; + newBoundary.status = CLIENT_RENDERED; + const thrownInfo = getThrownInfo(task.componentStack); + let errorDigest; + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone( + request, + postponeInstance.message, + thrownInfo, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + // TODO: Figure out a better signal than a magic digest value. + errorDigest = 'POSTPONE'; + } else { + errorDigest = logRecoverableError( + request, + error, + thrownInfo, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + } + encodeErrorForBoundary( + newBoundary, + errorDigest, error, thrownInfo, - __DEV__ && enableOwnerStacks ? task.debugTask : null, + false, ); - } - encodeErrorForBoundary(newBoundary, errorDigest, error, thrownInfo, false); - untrackBoundary(request, newBoundary); + untrackBoundary(request, newBoundary); - // We don't need to decrement any task numbers because we didn't spawn any new task. - // We don't need to schedule any task because we know the parent has written yet. - // We do need to fallthrough to create the fallback though. - } finally { - task.blockedBoundary = parentBoundary; - task.hoistableState = parentHoistableState; - task.blockedSegment = parentSegment; - task.keyPath = prevKeyPath; - } + // We don't need to decrement any task numbers because we didn't spawn any new task. + // We don't need to schedule any task because we know the parent has written yet. + // We do need to fallthrough to create the fallback though. + } finally { + task.blockedBoundary = parentBoundary; + task.hoistableState = parentHoistableState; + task.blockedSegment = parentSegment; + task.keyPath = prevKeyPath; + } - const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]]; - const trackedPostpones = request.trackedPostpones; - if (trackedPostpones !== null) { - // We create a detached replay node to track any postpones inside the fallback. - const fallbackReplayNode: ReplayNode = [ - fallbackKeyPath[1], - fallbackKeyPath[2], - ([]: Array), + const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]]; + // We create suspended task for the fallback because we don't want to actually work + // on it yet in case we finish the main content, so we queue for later. + const suspendedFallbackTask = createRenderTask( + request, null, - ]; - trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode); - if (newBoundary.status === POSTPONED) { - // This must exist now. - const boundaryReplayNode: ReplaySuspenseBoundary = - (trackedPostpones.workingMap.get(keyPath): any); - boundaryReplayNode[4] = fallbackReplayNode; - } else { - // We might not inject it into the postponed tree, unless the content actually - // postpones too. We need to keep track of it until that happpens. - newBoundary.trackedFallbackNode = fallbackReplayNode; - } + fallback, + -1, + parentBoundary, + boundarySegment, + newBoundary.fallbackState, + fallbackAbortSet, + fallbackKeyPath, + task.formatContext, + task.context, + task.treeContext, + task.componentStack, + true, + !disableLegacyContext ? task.legacyContext : emptyContextObject, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + pushComponentStack(suspendedFallbackTask); + // TODO: This should be queued at a separate lower priority queue so that we only work + // on preparing fallbacks if we don't have any more main content to task on. + request.pingedTasks.push(suspendedFallbackTask); } - // We create suspended task for the fallback because we don't want to actually work - // on it yet in case we finish the main content, so we queue for later. - const suspendedFallbackTask = createRenderTask( - request, - null, - fallback, - -1, - parentBoundary, - boundarySegment, - newBoundary.fallbackState, - fallbackAbortSet, - fallbackKeyPath, - task.formatContext, - task.context, - task.treeContext, - task.componentStack, - true, - !disableLegacyContext ? task.legacyContext : emptyContextObject, - __DEV__ && enableOwnerStacks ? task.debugTask : null, - ); - pushComponentStack(suspendedFallbackTask); - // TODO: This should be queued at a separate lower priority queue so that we only work - // on preparing fallbacks if we don't have any more main content to task on. - request.pingedTasks.push(suspendedFallbackTask); } function replaySuspenseBoundary( diff --git a/packages/react/index.fb.js b/packages/react/index.fb.js index 49259d9eaf50f..1b87e4b2e582f 100644 --- a/packages/react/index.fb.js +++ b/packages/react/index.fb.js @@ -39,6 +39,7 @@ export { use, useActionState, useCallback, + unstable_useContextWithBailout, useContext, useDebugValue, useDeferredValue, diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index fc668246a988b..318d8e648d9d5 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -38,6 +38,7 @@ import {postpone} from './ReactPostpone'; import { getCacheForType, useCallback, + unstable_useContextWithBailout, useContext, useEffect, useEffectEvent, @@ -83,6 +84,7 @@ export { cache, postpone as unstable_postpone, useCallback, + unstable_useContextWithBailout, useContext, useEffect, useEffectEvent as experimental_useEffectEvent, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 93d9fa28f07f9..956a2a96b44a1 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -19,6 +19,10 @@ import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {enableAsyncActions} from 'shared/ReactFeatureFlags'; +import { + enableContextProfiling, + enableLazyContextPropagation, +} from '../../shared/ReactFeatureFlags'; type BasicStateAction = (S => S) | S; type Dispatch = A => void; @@ -65,6 +69,27 @@ export function useContext(Context: ReactContext): T { return dispatcher.useContext(Context); } +export function unstable_useContextWithBailout( + context: ReactContext, + select: (T => Array) | null, +): T { + if (!(enableLazyContextPropagation && enableContextProfiling)) { + throw new Error('Not implemented.'); + } + + const dispatcher = resolveDispatcher(); + if (__DEV__) { + if (context.$$typeof === REACT_CONSUMER_TYPE) { + console.error( + 'Calling useContext(Context.Consumer) is not supported and will cause bugs. ' + + 'Did you mean to call useContext(Context) instead?', + ); + } + } + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.unstable_useContextWithBailout(context, select); +} + export function useState( initialState: (() => S) | S, ): [S, Dispatch>] { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 6d400f7deee08..b713e9f068f4b 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -97,6 +97,9 @@ export const enableTransitionTracing = false; // No known bugs, but needs performance testing export const enableLazyContextPropagation = false; +// Expose unstable useContext for performance testing +export const enableContextProfiling = false; + // FB-only usage. The new API has different semantics. export const enableLegacyHidden = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 961eeac850fc3..59c1a042fbfe4 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -57,6 +57,7 @@ export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = true; export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; +export const enableContextProfiling = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; export const enableLegacyHidden = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3cf7347c96bdb..0abb8ff65c9b0 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -50,6 +50,7 @@ export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = false; export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; +export const enableContextProfiling = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; export const enableLegacyHidden = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 5857ecde9b594..e41d98ce48cd9 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -52,6 +52,7 @@ export const transitionLaneExpirationMs = 5000; export const disableSchedulerTimeoutInWorkLoop = false; export const enableLazyContextPropagation = false; +export const enableContextProfiling = false; export const enableLegacyHidden = false; export const consoleManagedByDevToolsDuringStrictMode = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 866ee338ca514..99260d5d68d91 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -42,6 +42,7 @@ export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = false; export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; +export const enableContextProfiling = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; export const enableLegacyHidden = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index ed426c39641b4..30bd7aea8a2f7 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -55,6 +55,7 @@ export const transitionLaneExpirationMs = 5000; export const disableSchedulerTimeoutInWorkLoop = false; export const enableLazyContextPropagation = false; +export const enableContextProfiling = false; export const enableLegacyHidden = false; export const consoleManagedByDevToolsDuringStrictMode = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 860769c3b95e7..5cbbd779d556a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -78,6 +78,8 @@ export const enableTaint = false; export const enablePostpone = false; +export const enableContextProfiling = true; + // TODO: www currently relies on this feature. It's disabled in open source. // Need to remove it. export const disableCommentsAsDOMContainers = false; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index ff87311764f6c..ba4fb4fa28428 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -525,5 +525,6 @@ "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.", "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated.", "539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.", - "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams." -} + "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.", + "541": "Compared context values must be arrays" +} \ No newline at end of file diff --git a/scripts/release/download-experimental-build-ghaction.js b/scripts/release/download-experimental-build-ghaction.js index 8f670f4e96403..a30555cc8c7c5 100755 --- a/scripts/release/download-experimental-build-ghaction.js +++ b/scripts/release/download-experimental-build-ghaction.js @@ -3,12 +3,13 @@ 'use strict'; const {join, relative} = require('path'); -const {logPromise, handleError} = require('./utils'); +const {handleError} = require('./utils'); const yargs = require('yargs'); const clear = require('clear'); const theme = require('./theme'); -const {exec} = require('child-process-promise'); -const {existsSync} = require('fs'); +const { + downloadBuildArtifacts, +} = require('./shared-commands/download-build-artifacts-ghaction'); const argv = yargs.wrap(yargs.terminalWidth()).options({ releaseChannel: { @@ -46,122 +47,6 @@ function printSummary(commit) { console.log(message.replace(/\n +/g, '\n').trim()); } -const OWNER = 'facebook'; -const REPO = 'react'; -const WORKFLOW_ID = 'runtime_build_and_test.yml'; -const GITHUB_HEADERS = ` - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${process.env.GH_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28"`.trim(); - -function getWorkflowId() { - if (existsSync(join(__dirname, `../../.github/workflows/${WORKFLOW_ID}`))) { - return WORKFLOW_ID; - } else { - throw new Error( - `Incorrect workflow ID: .github/workflows/${WORKFLOW_ID} does not exist. Please check the name of the workflow being downloaded from.` - ); - } -} - -async function getWorkflowRunId(commit) { - const res = await exec( - `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/workflows/${getWorkflowId()}/runs?head_sha=${commit}&branch=main&exclude_pull_requests=true` - ); - - const json = JSON.parse(res.stdout); - let workflowRun; - if (json.total_count === 1) { - workflowRun = json.workflow_runs[0]; - } else { - workflowRun = json.workflow_runs.find( - run => run.head_sha === commit && run.head_branch === 'main' - ); - } - - if (workflowRun == null || workflowRun.id == null) { - console.log( - theme`{error The workflow run for the specified commit (${commit}) could not be found.}` - ); - process.exit(1); - } - - return workflowRun.id; -} - -async function getArtifact(workflowRunId, artifactName) { - const res = await exec( - `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/runs/${workflowRunId}/artifacts?per_page=100&name=${artifactName}` - ); - - const json = JSON.parse(res.stdout); - let artifact; - if (json.total_count === 1) { - artifact = json.artifacts[0]; - } else { - artifact = json.artifacts.find( - _artifact => _artifact.name === artifactName - ); - } - - if (artifact == null) { - console.log( - theme`{error The specified workflow run (${workflowRunId}) does not contain any build artifacts.}` - ); - process.exit(1); - } - - return artifact; -} - -async function downloadArtifactsFromGitHub(commit, releaseChannel) { - const workflowRunId = await getWorkflowRunId(commit); - const artifact = await getArtifact(workflowRunId, 'artifacts_combined'); - - // Download and extract artifact - const cwd = join(__dirname, '..', '..'); - await exec(`rm -rf ./build`, {cwd}); - await exec( - `curl -L ${GITHUB_HEADERS} ${artifact.archive_download_url} \ - > a.zip && unzip a.zip -d . && rm a.zip build2.tgz && tar -xvzf build.tgz && rm build.tgz`, - { - cwd, - } - ); - - // Copy to staging directory - // TODO: Consider staging the release in a different directory from the CI - // build artifacts: `./build/node_modules` -> `./staged-releases` - if (!existsSync(join(cwd, 'build'))) { - await exec(`mkdir ./build`, {cwd}); - } else { - await exec(`rm -rf ./build/node_modules`, {cwd}); - } - let sourceDir; - // TODO: Rename release channel to `next` - if (releaseChannel === 'stable') { - sourceDir = 'oss-stable'; - } else if (releaseChannel === 'experimental') { - sourceDir = 'oss-experimental'; - } else if (releaseChannel === 'rc') { - sourceDir = 'oss-stable-rc'; - } else if (releaseChannel === 'latest') { - sourceDir = 'oss-stable-semver'; - } else { - console.error('Internal error: Invalid release channel: ' + releaseChannel); - process.exit(releaseChannel); - } - await exec(`cp -r ./build/${sourceDir} ./build/node_modules`, {cwd}); -} - -async function downloadBuildArtifacts(commit, releaseChannel) { - const label = theme`commit {commit ${commit}})`; - return logPromise( - downloadArtifactsFromGitHub(commit, releaseChannel), - theme`Downloading artifacts from GitHub for ${label}` - ); -} - const main = async () => { try { await downloadBuildArtifacts(argv.commit, argv.releaseChannel); @@ -171,11 +56,4 @@ const main = async () => { } }; -if (process.env.GH_TOKEN == null) { - console.log( - theme`{error Expected GH_TOKEN to be provided as an env variable}` - ); - process.exit(1); -} - main(); diff --git a/scripts/release/prepare-release-from-ci.js b/scripts/release/prepare-release-from-ci.js index 80dd051930785..5657760ecde59 100755 --- a/scripts/release/prepare-release-from-ci.js +++ b/scripts/release/prepare-release-from-ci.js @@ -5,7 +5,9 @@ const {join} = require('path'); const {addDefaultParamValue, handleError} = require('./utils'); -const downloadBuildArtifacts = require('./shared-commands/download-build-artifacts'); +const { + downloadBuildArtifacts, +} = require('./shared-commands/download-build-artifacts-ghaction'); const parseParams = require('./shared-commands/parse-params'); const printPrereleaseSummary = require('./shared-commands/print-prerelease-summary'); const testPackagingFixture = require('./shared-commands/test-packaging-fixture'); @@ -17,7 +19,10 @@ const run = async () => { const params = await parseParams(); params.cwd = join(__dirname, '..', '..'); - await downloadBuildArtifacts(params); + await downloadBuildArtifacts( + params.commit, + params.releaseChannel ?? process.env.RELEASE_CHANNEL + ); if (!params.skipTests) { await testPackagingFixture(params); diff --git a/scripts/release/shared-commands/download-build-artifacts-ghaction.js b/scripts/release/shared-commands/download-build-artifacts-ghaction.js new file mode 100644 index 0000000000000..81472b40b071c --- /dev/null +++ b/scripts/release/shared-commands/download-build-artifacts-ghaction.js @@ -0,0 +1,136 @@ +'use strict'; + +const {join} = require('path'); +const theme = require('../theme'); +const {exec} = require('child-process-promise'); +const {existsSync} = require('fs'); +const {logPromise} = require('../utils'); + +if (process.env.GH_TOKEN == null) { + console.log( + theme`{error Expected GH_TOKEN to be provided as an env variable}` + ); + process.exit(1); +} + +const OWNER = 'facebook'; +const REPO = 'react'; +const WORKFLOW_ID = 'runtime_build_and_test.yml'; +const GITHUB_HEADERS = ` + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${process.env.GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28"`.trim(); + +function getWorkflowId() { + if ( + existsSync(join(__dirname, `../../../.github/workflows/${WORKFLOW_ID}`)) + ) { + return WORKFLOW_ID; + } else { + throw new Error( + `Incorrect workflow ID: .github/workflows/${WORKFLOW_ID} does not exist. Please check the name of the workflow being downloaded from.` + ); + } +} + +async function getWorkflowRunId(commit) { + const res = await exec( + `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/workflows/${getWorkflowId()}/runs?head_sha=${commit}&branch=main&exclude_pull_requests=true` + ); + + const json = JSON.parse(res.stdout); + let workflowRun; + if (json.total_count === 1) { + workflowRun = json.workflow_runs[0]; + } else { + workflowRun = json.workflow_runs.find( + run => run.head_sha === commit && run.head_branch === 'main' + ); + } + + if (workflowRun == null || workflowRun.id == null) { + console.log( + theme`{error The workflow run for the specified commit (${commit}) could not be found.}` + ); + process.exit(1); + } + + return workflowRun.id; +} + +async function getArtifact(workflowRunId, artifactName) { + const res = await exec( + `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/runs/${workflowRunId}/artifacts?per_page=100&name=${artifactName}` + ); + + const json = JSON.parse(res.stdout); + let artifact; + if (json.total_count === 1) { + artifact = json.artifacts[0]; + } else { + artifact = json.artifacts.find( + _artifact => _artifact.name === artifactName + ); + } + + if (artifact == null) { + console.log( + theme`{error The specified workflow run (${workflowRunId}) does not contain any build artifacts.}` + ); + process.exit(1); + } + + return artifact; +} + +async function downloadArtifactsFromGitHub(commit, releaseChannel) { + const workflowRunId = await getWorkflowRunId(commit); + const artifact = await getArtifact(workflowRunId, 'artifacts_combined'); + + // Download and extract artifact + const cwd = join(__dirname, '..', '..', '..'); + await exec(`rm -rf ./build`, {cwd}); + await exec( + `curl -L ${GITHUB_HEADERS} ${artifact.archive_download_url} \ + > a.zip && unzip a.zip -d . && rm a.zip build2.tgz && tar -xvzf build.tgz && rm build.tgz`, + { + cwd, + } + ); + + // Copy to staging directory + // TODO: Consider staging the release in a different directory from the CI + // build artifacts: `./build/node_modules` -> `./staged-releases` + if (!existsSync(join(cwd, 'build'))) { + await exec(`mkdir ./build`, {cwd}); + } else { + await exec(`rm -rf ./build/node_modules`, {cwd}); + } + let sourceDir; + // TODO: Rename release channel to `next` + if (releaseChannel === 'stable') { + sourceDir = 'oss-stable'; + } else if (releaseChannel === 'experimental') { + sourceDir = 'oss-experimental'; + } else if (releaseChannel === 'rc') { + sourceDir = 'oss-stable-rc'; + } else if (releaseChannel === 'latest') { + sourceDir = 'oss-stable-semver'; + } else { + console.error('Internal error: Invalid release channel: ' + releaseChannel); + process.exit(releaseChannel); + } + await exec(`cp -r ./build/${sourceDir} ./build/node_modules`, {cwd}); +} + +async function downloadBuildArtifacts(commit, releaseChannel) { + const label = theme`commit {commit ${commit}})`; + return logPromise( + downloadArtifactsFromGitHub(commit, releaseChannel), + theme`Downloading artifacts from GitHub for ${label}` + ); +} + +module.exports = { + downloadBuildArtifacts, +};