From 675a29c3e9869d0706dc5ed08779ae04186bd44f Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Sun, 23 Aug 2026 17:29:26 +0200 Subject: [PATCH 1/2] [ci] Rebuild sizebot on the GitHub Actions token (#37322) The build size comparison comment was posted by Danger, which authenticated with a personal access token hardcoded in `scripts/tasks/danger.js`. That token has since been revoked, so sizebot has been posting nothing at all (due to e.g. https://github.com/react/react/actions/runs/32181295467/job/95855395224?pr=37315). This change rebuilds it on the short-lived `GITHUB_TOKEN` that Actions mints per run and a new workflow only responsible for rendering untrusted JSON input as markdown in a PR comment. A straight token swap would not have worked. Fork pull requests did receive sizebot comments, but only because the token was in checked-out source: the sizebot job runs on the `pull_request` trigger, where a fork's `GITHUB_TOKEN` is read-only and cannot comment. The comment therefore moves to a new `workflow_run` workflow, `runtime_sizebot_comment.yml`, which runs in this repository with a writable token no matter where the pull request came from. It posts a placeholder when a build is requested and rewrites it in place when the build completes, fails, is cancelled, or is held for maintainer approval. The measurement stays on the unprivileged side of that boundary which are recorded as raw sizes into a `sizebot-results` artifact, and the new workflow downloads only that JSON and renders it from a default-branch checkout. The job holding `pull-requests: write` never unpacks a build produced by a fork, which matters because the existing base-build download justifies using an unverified artifact on the grounds that the job has restricted permissions. Thresholds, the critical bundle list, and the comment template all live on the trusted side, and the renderer validates every field it reads out of the artifact so that a crafted build path cannot inject markdown. The pull request number is resolved from the API rather than from the artifact, since a number read from fork-controlled data would let any contributor post a bot comment on an arbitrary pull request. Resolving that number needs a branch lookup rather than any of the obvious approaches. `workflow_run.pull_requests` is empty for fork runs, and neither `commits/{sha}/pulls` nor the search API indexes fork pull request head commits, so the workflow looks the pull request up by `owner:ref` instead. A comment is only ever left alone in one situation: when it already describes the pull request's current head and the event being handled belongs to an older commit. Everything else is written, and marked stale whenever the report does not describe the current head. That single rule covers both an old run finishing after a force push and a new build superseding a report already on display, and in the latter case the previous numbers stay visible instead of being blanked back to a placeholder. The results file carries a `version` field. Its writer is whatever `compare-sizes.js` a pull request branch happens to carry, while its reader is on the default branch, so the two can mismatch and the renderer needs to be able to say so instead of misrendering a table. Porting the table fixed a longstanding bug in `change()`. Testing `decimal < 0.0001` reported every size decrease as unchanged, which is why `signDisplay: 'exceptZero'` never had a negative number to render: a 709.04 kB to 708.68 kB drop printed as `=`. It now compares the magnitude. Co-authored-by: Claude Code (kimi-k3[1m]) --- .eslintrc.js | 1 - .github/workflows/runtime_build_and_test.yml | 17 +- .github/workflows/runtime_sizebot_comment.yml | 105 ++++ .gitignore | 6 + dangerfile.js | 282 ---------- package.json | 1 - scripts/sizebot/compare-sizes.js | 114 ++++ scripts/sizebot/pull-request-comment.js | 253 +++++++++ scripts/sizebot/render-comment.js | 489 ++++++++++++++++++ scripts/tasks/danger.js | 39 -- yarn.lock | 347 +------------ 11 files changed, 990 insertions(+), 664 deletions(-) create mode 100644 .github/workflows/runtime_sizebot_comment.yml delete mode 100644 dangerfile.js create mode 100644 scripts/sizebot/compare-sizes.js create mode 100644 scripts/sizebot/pull-request-comment.js create mode 100644 scripts/sizebot/render-comment.js delete mode 100644 scripts/tasks/danger.js diff --git a/.eslintrc.js b/.eslintrc.js index e197bbf95271..f7f62d2bedd9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -433,7 +433,6 @@ module.exports = { 'packages/*/npm/**/*.js', 'packages/dom-event-testing-library/**/*.js', 'packages/react-devtools*/**/*.js', - 'dangerfile.js', 'fixtures', 'packages/react-dom/src/test-utils/*.js', ], diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 2c91491da19e..8789cd75e8fc 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -1,3 +1,7 @@ +# Keep this name in sync with the `workflow_run.workflows` list in +# runtime_sizebot_comment.yml, which matches on this exact string rather than on +# the file name. Renaming it here alone stops sizebot from ever commenting again, +# and nothing fails: the comment workflow simply never triggers. name: (Runtime) Build and Test on: @@ -930,10 +934,15 @@ jobs: - name: Display structure of build for PR run: ls -R build - run: echo ${{ github.event.pull_request.head.sha || github.sha }} >> build/COMMIT_SHA - - run: node ./scripts/tasks/danger + - name: Measure size changes + # Only measures and records the numbers. The comment is rendered and + # posted by runtime_sizebot_comment.yml, which runs on the workflow_run + # trigger because this job's token is read-only for pull requests from + # forks and so cannot comment. + run: node ./scripts/sizebot/compare-sizes.js - name: Archive sizebot results uses: actions/upload-artifact@v4 with: - name: sizebot-message - path: sizebot-message.md - if-no-files-found: ignore + name: sizebot-results + path: sizebot-results.json + if-no-files-found: error diff --git a/.github/workflows/runtime_sizebot_comment.yml b/.github/workflows/runtime_sizebot_comment.yml new file mode 100644 index 000000000000..5d6909650f41 --- /dev/null +++ b/.github/workflows/runtime_sizebot_comment.yml @@ -0,0 +1,105 @@ +name: (Runtime) Sizebot Comment + +# Posts the build size comparison comment on pull requests. +# +# This has to be a separate `workflow_run` workflow rather than a job inside +# (Runtime) Build and Test: that workflow runs on the `pull_request` trigger, so +# a pull request from a fork gets a read-only token and cannot comment. A +# `workflow_run` workflow always runs in the context of this repository, on the +# default branch, with a writable token. +# +# The measurement happens on the other side of that boundary, in the unprivileged +# sizebot job, which uploads a `sizebot-results` artifact. This workflow only +# downloads that small JSON file and renders it. It deliberately never unpacks a +# build produced by a fork, because it holds a token that can write to the +# repository. + +on: + workflow_run: + workflows: ['(Runtime) Build and Test'] + types: [requested, completed] + +permissions: {} + +concurrency: + # Serialize per pull request. Both the requested and completed handlers read + # the existing comment, decide against it and write it back, so they must not + # interleave. Never cancel: every event either updates the comment or is + # deliberately skipped, and dropping one loses a state transition. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +env: + TZ: /usr/share/zoneinfo/America/Los_Angeles + +jobs: + comment: + # Only pull request builds get a size comment. Runs from `push` and + # `workflow_dispatch` have no pull request to comment on. + if: ${{ github.event.workflow_run.event == 'pull_request' }} + name: Comment with size changes + runs-on: ubuntu-latest + permissions: + # We use github.token to download the sizebot results artifact from the + # triggering runtime_build_and_test.yml run + actions: read + # Used to check out the renderer this workflow runs + contents: read + # Used to create and update the sizebot comment on the pull request + pull-requests: write + steps: + # No `ref`, so this is the default branch rather than the pull request. + # The thresholds, the critical bundle list and the comment template all + # come from here and cannot be changed by the pull request being measured. + - uses: actions/checkout@v4 + with: + # This job holds a token that can write to the repository, and it has + # no use for git credentials after the checkout. + persist-credentials: false + + - name: Resolve pull request and existing comment + id: resolve + uses: actions/github-script@v7 + with: + script: | + const {resolve} = require(`${process.env.GITHUB_WORKSPACE}/scripts/sizebot/pull-request-comment.js`); + await resolve({github, context, core}); + + - name: Download sizebot results + if: ${{ steps.resolve.outputs.action == 'continue' && steps.resolve.outputs.download_results == 'true' }} + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: sizebot-results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Render comment + if: ${{ steps.resolve.outputs.action == 'continue' }} + run: node ./scripts/sizebot/render-comment.js + + - name: Archive full size report + # Only written when the report is too large to fit in a comment, in which + # case the comment links to this artifact. + if: ${{ steps.resolve.outputs.action == 'continue' && hashFiles('sizebot-message.md') != '' }} + uses: actions/upload-artifact@v4 + with: + name: sizebot-message + path: sizebot-message.md + + - name: Post comment + if: ${{ steps.resolve.outputs.action == 'continue' }} + uses: actions/github-script@v7 + with: + script: | + const {post} = require(`${process.env.GITHUB_WORKSPACE}/scripts/sizebot/pull-request-comment.js`); + await post({github, context, core}); + + - name: Fail if the build configuration drifted + # The comment is posted first, so it explains the problem on the pull + # request itself. This step exists so the drift also shows up as a failed + # run rather than only in a comment. + if: ${{ steps.resolve.outputs.action == 'continue' && hashFiles('sizebot-problem.txt') != '' }} + run: | + cat sizebot-problem.txt + exit 1 diff --git a/.gitignore b/.gitignore index 9f0da5163e69..223077751e4c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ scripts/flow/*/.flowconfig _SpecRunner.html __benchmarks__ build/ +base-build/ +sizebot-comment.md +sizebot-context.json +sizebot-message.md +sizebot-problem.txt +sizebot-results.json remote-repo/ coverage/ .module-cache diff --git a/dangerfile.js b/dangerfile.js deleted file mode 100644 index b7ee95d75f88..000000000000 --- a/dangerfile.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and 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'; - -/* eslint-disable no-for-of-loops/no-for-of-loops */ - -// Hi, if this is your first time editing/reading a Dangerfile, here's a summary: -// It's a JS runtime which helps you provide continuous feedback inside GitHub. -// -// You can see the docs here: http://danger.systems/js/ -// -// If you want to test changes Danger, I'd recommend checking out an existing PR -// and then running the `danger pr` command. -// -// You'll need a GitHub token, you can re-use this one: -// -// 0a7d5c3cad9a6dbec2d9 9a5222cf49062a4c1ef7 -// -// (Just remove the space) -// -// So, for example: -// -// `DANGER_GITHUB_API_TOKEN=[ENV_ABOVE] yarn danger pr https://github.com/facebook/react/pull/11865 - -const {markdown, danger, warn} = require('danger'); -const {promisify} = require('util'); -const glob = promisify(require('glob')); -const gzipSize = require('gzip-size'); -const {writeFileSync} = require('fs'); - -const {readFileSync, statSync} = require('fs'); - -const BASE_DIR = 'base-build'; -const HEAD_DIR = 'build'; - -const CRITICAL_THRESHOLD = 0.02; -const SIGNIFICANCE_THRESHOLD = 0.002; -const CRITICAL_ARTIFACT_PATHS = new Set([ - // We always report changes to these bundles, even if the change is - // insignificant or non-existent. - 'oss-stable/react-dom/cjs/react-dom.production.js', - 'oss-stable/react-dom/cjs/react-dom-client.production.js', - 'oss-experimental/react-dom/cjs/react-dom.production.js', - 'oss-experimental/react-dom/cjs/react-dom-client.production.js', - 'facebook-www/ReactDOM-prod.classic.js', - 'facebook-www/ReactDOM-prod.modern.js', -]); - -const kilobyteFormatter = new Intl.NumberFormat('en', { - style: 'unit', - unit: 'kilobyte', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -function kbs(bytes) { - return kilobyteFormatter.format(bytes / 1000); -} - -const percentFormatter = new Intl.NumberFormat('en', { - style: 'percent', - signDisplay: 'exceptZero', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -function change(decimal) { - if (decimal === Infinity) { - return 'New file'; - } - if (decimal === -1) { - return 'Deleted'; - } - if (decimal < 0.0001) { - return '='; - } - return percentFormatter.format(decimal); -} - -const header = ` - | Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip | - | ---- | --- | ---- | ------- | -------- | --------- | ------------ |`; - -function row(result, baseSha, headSha) { - const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`; - const rowArr = [ - `| [${result.path}](${diffViewUrl})`, - `**${change(result.change)}**`, - `${kbs(result.baseSize)}`, - `${kbs(result.headSize)}`, - `${change(result.changeGzip)}`, - `${kbs(result.baseSizeGzip)}`, - `${kbs(result.headSizeGzip)}`, - ]; - return rowArr.join(' | '); -} - -(async function () { - // Use git locally to grab the commit which represents the place - // where the branches differ - - const upstreamRepo = danger.github.pr.base.repo.full_name; - if (upstreamRepo !== 'react/react') { - // Exit unless we're running in the main repo - return; - } - - let headSha; - let baseSha; - try { - headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim(); - baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim(); - } catch { - warn( - "Failed to read build artifacts. It's possible a build configuration " + - 'has changed upstream. Try pulling the latest changes from the ' + - 'main branch.' - ); - return; - } - - // Disable sizeBot in a Devtools Pull Request. Because that doesn't affect production bundle size. - const commitFiles = [ - ...danger.git.created_files, - ...danger.git.deleted_files, - ...danger.git.modified_files, - ]; - if ( - commitFiles.every(filename => filename.includes('packages/react-devtools')) - ) - return; - - const resultsMap = new Map(); - - // Find all the head (current) artifacts paths. - const headArtifactPaths = await glob('**/*.js', {cwd: 'build'}); - for (const artifactPath of headArtifactPaths) { - try { - // This will throw if there's no matching base artifact - const baseSize = statSync(BASE_DIR + '/' + artifactPath).size; - const baseSizeGzip = gzipSize.fileSync(BASE_DIR + '/' + artifactPath); - - const headSize = statSync(HEAD_DIR + '/' + artifactPath).size; - const headSizeGzip = gzipSize.fileSync(HEAD_DIR + '/' + artifactPath); - resultsMap.set(artifactPath, { - path: artifactPath, - headSize, - headSizeGzip, - baseSize, - baseSizeGzip, - change: (headSize - baseSize) / baseSize, - changeGzip: (headSizeGzip - baseSizeGzip) / baseSizeGzip, - }); - } catch { - // There's no matching base artifact. This is a new file. - const baseSize = 0; - const baseSizeGzip = 0; - const headSize = statSync(HEAD_DIR + '/' + artifactPath).size; - const headSizeGzip = gzipSize.fileSync(HEAD_DIR + '/' + artifactPath); - resultsMap.set(artifactPath, { - path: artifactPath, - headSize, - headSizeGzip, - baseSize, - baseSizeGzip, - change: Infinity, - changeGzip: Infinity, - }); - } - } - - // Check for base artifacts that were deleted in the head. - const baseArtifactPaths = await glob('**/*.js', {cwd: 'base-build'}); - for (const artifactPath of baseArtifactPaths) { - if (!resultsMap.has(artifactPath)) { - const baseSize = statSync(BASE_DIR + '/' + artifactPath).size; - const baseSizeGzip = gzipSize.fileSync(BASE_DIR + '/' + artifactPath); - const headSize = 0; - const headSizeGzip = 0; - resultsMap.set(artifactPath, { - path: artifactPath, - headSize, - headSizeGzip, - baseSize, - baseSizeGzip, - change: -1, - changeGzip: -1, - }); - } - } - - const results = Array.from(resultsMap.values()); - results.sort((a, b) => b.change - a.change); - - let criticalResults = []; - for (const artifactPath of CRITICAL_ARTIFACT_PATHS) { - const result = resultsMap.get(artifactPath); - if (result === undefined) { - throw new Error( - 'Missing expected bundle. If this was an intentional change to the ' + - 'build configuration, update Dangerfile.js accordingly: ' + - artifactPath - ); - } - criticalResults.push(row(result, baseSha, headSha)); - } - - let significantResults = []; - for (const result of results) { - // If result exceeds critical threshold, add to top section. - if ( - (result.change > CRITICAL_THRESHOLD || - 0 - result.change > CRITICAL_THRESHOLD || - // New file - result.change === Infinity || - // Deleted file - result.change === -1) && - // Skip critical artifacts. We added those earlier, in a fixed order. - !CRITICAL_ARTIFACT_PATHS.has(result.path) - ) { - criticalResults.push(row(result, baseSha, headSha)); - } - - // Do the same for results that exceed the significant threshold. These - // will go into the bottom, collapsed section. Intentionally including - // critical artifacts in this section, too. - if ( - result.change > SIGNIFICANCE_THRESHOLD || - 0 - result.change > SIGNIFICANCE_THRESHOLD || - result.change === Infinity || - result.change === -1 - ) { - significantResults.push(row(result, baseSha, headSha)); - } - } - - const message = ` -Comparing: ${baseSha}...${headSha} - -## Critical size changes - -Includes critical production bundles, as well as any change greater than ${ - CRITICAL_THRESHOLD * 100 - }%: - -${header} -${criticalResults.join('\n')} - -## Significant size changes - -Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%: - -${ - significantResults.length > 0 - ? ` -
-Expand to show -${header} -${significantResults.join('\n')} -
-` - : '(No significant changes)' -} -`; - - // GitHub comments are limited to 65536 characters. - if (message.length > 65536) { - // Make message available as an artifact - writeFileSync('sizebot-message.md', message); - markdown( - 'The size diff is too large to display in a single comment. ' + - `The GitHub action for this pull request contains an artifact called 'sizebot-message.md' with the full message.` - ); - } else { - markdown(message); - } -})(); diff --git a/package.json b/package.json index 29abc8c0dafc..f5e4f1d910ff 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "confusing-browser-globals": "^1.0.9", "core-js": "^3.6.4", "create-react-class": "^15.6.3", - "danger": "^11.2.3", "error-stack-parser": "^2.0.6", "eslint": "^7.7.0", "eslint-config-prettier": "^6.9.0", diff --git a/scripts/sizebot/compare-sizes.js b/scripts/sizebot/compare-sizes.js new file mode 100644 index 000000000000..6e14a604956f --- /dev/null +++ b/scripts/sizebot/compare-sizes.js @@ -0,0 +1,114 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +// Measures every build artifact in `build` against the base revision's artifacts +// in `base-build` and writes the raw numbers to `sizebot-results.json`. +// +// This runs in the `pull_request` half of CI, where the GitHub token is +// read-only, so it never talks to the API and never renders anything +// user-facing. `render-comment.js` turns this JSON into the pull request comment +// from a trusted checkout. See `.github/workflows/runtime_sizebot_comment.yml` +// for why the two halves are separate. + +const {promisify} = require('util'); +const glob = promisify(require('glob')); +const gzipSize = require('gzip-size'); +const {readFileSync, statSync, writeFileSync} = require('fs'); + +// Bump on any incompatible change to the JSON below: added required fields, +// renamed or removed fields, or a changed meaning for an existing one. Purely +// additive optional fields do not need a bump. The reader lives on the default +// branch while the writer lives on the pull request branch, so the two can +// legitimately disagree and `render-comment.js` needs to be able to tell. +const RESULTS_VERSION = 1; + +const RESULTS_PATH = 'sizebot-results.json'; +const BASE_DIR = 'base-build'; +const HEAD_DIR = 'build'; + +function measure(dir, artifactPath) { + const file = dir + '/' + artifactPath; + return { + size: statSync(file).size, + sizeGzip: gzipSize.fileSync(file), + }; +} + +function writeResults(results) { + writeFileSync(RESULTS_PATH, JSON.stringify(results, null, 2) + '\n'); +} + +(async function () { + let headSha; + let baseSha; + try { + headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim(); + baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim(); + } catch { + // Let the renderer explain this one. It is expected to happen whenever the + // build configuration changes upstream, which is not a CI failure. + writeResults({ + version: RESULTS_VERSION, + status: 'base-artifacts-unavailable', + }); + return; + } + + // A missing size is recorded as null rather than 0, so the renderer can tell + // "this artifact does not exist on that side" apart from "this artifact is + // empty". It derives the new-file and deleted-file cases from those nulls. + const artifactsByPath = new Map(); + + const headArtifactPaths = await glob('**/*.js', {cwd: HEAD_DIR}); + for (const artifactPath of headArtifactPaths) { + let base; + try { + base = measure(BASE_DIR, artifactPath); + } catch { + // There's no matching base artifact. This is a new file. + base = null; + } + const head = measure(HEAD_DIR, artifactPath); + artifactsByPath.set(artifactPath, { + path: artifactPath, + baseSize: base === null ? null : base.size, + baseSizeGzip: base === null ? null : base.sizeGzip, + headSize: head.size, + headSizeGzip: head.sizeGzip, + }); + } + + // Check for base artifacts that were deleted in the head. + const baseArtifactPaths = await glob('**/*.js', {cwd: BASE_DIR}); + for (const artifactPath of baseArtifactPaths) { + if (!artifactsByPath.has(artifactPath)) { + const base = measure(BASE_DIR, artifactPath); + artifactsByPath.set(artifactPath, { + path: artifactPath, + baseSize: base.size, + baseSizeGzip: base.sizeGzip, + headSize: null, + headSizeGzip: null, + }); + } + } + + // Every artifact is reported, with no threshold filtering. The thresholds and + // the critical bundle list belong to the renderer, so that a pull request + // cannot quietly widen them to hide a regression. + writeResults({ + version: RESULTS_VERSION, + status: 'ok', + baseSha, + headSha, + artifacts: Array.from(artifactsByPath.values()), + }); +})(); diff --git a/scripts/sizebot/pull-request-comment.js b/scripts/sizebot/pull-request-comment.js new file mode 100644 index 000000000000..17056fef4825 --- /dev/null +++ b/scripts/sizebot/pull-request-comment.js @@ -0,0 +1,253 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +// The GitHub API half of sizebot, called from `actions/github-script` steps in +// `.github/workflows/runtime_sizebot_comment.yml`. +// +// `resolve` figures out which pull request a `workflow_run` event belongs to, +// finds any comment sizebot has already left on it, and decides whether this +// event should write at all. `post` creates or updates the comment from the body +// that `render-comment.js` produced in between. + +const {readFileSync, writeFileSync} = require('fs'); +const { + MARKER_PREFIX, + extractReport, + parseReportHead, +} = require('./render-comment'); + +const CONTEXT_PATH = 'sizebot-context.json'; +const COMMENT_PATH = 'sizebot-comment.md'; + +const COMMENT_AUTHOR = 'github-actions[bot]'; + +// `pulls.listFiles` stops paginating here, so a pull request larger than this +// cannot be shown to touch only DevTools. +const MAX_LISTABLE_FILES = 3000; + +const DEVTOOLS_PATH = 'packages/react-devtools'; + +async function findExistingComment(github, context, prNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + for (const comment of comments) { + if ( + comment.user.login === COMMENT_AUTHOR && + comment.body.startsWith(MARKER_PREFIX) + ) { + return comment; + } + } + return null; +} + +// `workflow_run.pull_requests` is empty for runs triggered by a fork, and +// neither `commits/{sha}/pulls` nor the search API index fork pull request head +// commits. Looking the branch up by `owner:ref` is what actually works for both +// fork and same-repo pull requests. +async function findPullRequestNumber(github, context, workflowRun) { + if ( + workflowRun.pull_requests != null && + workflowRun.pull_requests.length > 0 + ) { + return workflowRun.pull_requests[0].number; + } + + if (workflowRun.head_repository == null) { + return null; + } + + const {data: pulls} = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${workflowRun.head_repository.owner.login}:${workflowRun.head_branch}`, + state: 'open', + per_page: 100, + }); + if (pulls.length === 0) { + return null; + } + return pulls[0].number; +} + +async function findPullRequest(github, context, workflowRun) { + const number = await findPullRequestNumber(github, context, workflowRun); + if (number === null) { + return null; + } + // Always finish with `pulls.get`. The list endpoint omits `changed_files`, + // which `isDevToolsOnly` needs, and that is the endpoint the fork path uses. + const {data} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }); + return data; +} + +async function isDevToolsOnly(github, context, pullRequest) { + if ( + !Number.isInteger(pullRequest.changed_files) || + // `listFiles` would silently truncate, and a truncated list can look + // DevTools-only when it is not. + pullRequest.changed_files > MAX_LISTABLE_FILES + ) { + return false; + } + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequest.number, + per_page: 100, + }); + if (files.length === 0) { + return false; + } + return files.every(file => file.filename.includes(DEVTOOLS_PATH)); +} + +async function resolve({github, context, core}) { + const workflowRun = context.payload.workflow_run; + const action = context.payload.action; + + const pullRequest = await findPullRequest(github, context, workflowRun); + if (pullRequest === null) { + core.info('No open pull request for this run. Nothing to comment on.'); + core.setOutput('action', 'skip'); + return; + } + + // A pull request number must never come from the build artifact, which the + // fork controls. Confirm the one we resolved really does belong to this run. + const runRepo = workflowRun.head_repository?.full_name ?? null; + const pullRequestRepo = pullRequest.head.repo?.full_name ?? null; + if (runRepo === null || pullRequestRepo === null) { + // A deleted fork leaves us no way to check, so don't write anything. + core.info('Head repository is unavailable. Nothing to comment on.'); + core.setOutput('action', 'skip'); + return; + } + if (pullRequestRepo !== runRepo) { + core.setFailed( + `Pull request #${pullRequest.number} has head repository ` + + `${pullRequestRepo}, but the run came from ${runRepo}.` + ); + return; + } + + // Mirrors the sizebot job's own condition in runtime_build_and_test.yml. + if (pullRequest.base.ref !== 'main') { + core.info( + `Pull request #${pullRequest.number} targets ${pullRequest.base.ref}, not main.` + ); + core.setOutput('action', 'skip'); + return; + } + + const existing = await findExistingComment( + github, + context, + pullRequest.number + ); + const existingReportHead = + existing === null ? null : parseReportHead(existing.body); + + // The only event we ever drop: a comment already describes the current head, + // and this event is about an older commit. Without this, a run cancelled by a + // force push reports `cancelled` after the newer run's comment has landed and + // replaces good numbers with a cancellation notice. + if ( + existingReportHead !== null && + existingReportHead === pullRequest.head.sha && + workflowRun.head_sha !== pullRequest.head.sha + ) { + core.info( + `Comment already reports on ${pullRequest.head.sha}; this run is for ` + + `${workflowRun.head_sha}. Leaving it alone.` + ); + core.setOutput('action', 'skip'); + return; + } + + const devtoolsOnly = + action === 'completed' + ? await isDevToolsOnly(github, context, pullRequest) + : false; + + writeFileSync( + CONTEXT_PATH, + JSON.stringify( + { + action, + prNumber: pullRequest.number, + prHeadSha: pullRequest.head.sha, + runHeadSha: workflowRun.head_sha, + runUrl: workflowRun.html_url, + runStatus: workflowRun.status, + runConclusion: workflowRun.conclusion, + devtoolsOnly, + existingCommentId: existing === null ? null : existing.id, + existingReportHead, + existingReport: existing === null ? null : extractReport(existing.body), + commentRunUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + }, + null, + 2 + ) + '\n' + ); + + core.setOutput('action', 'continue'); + // Only a completed run can have produced results to download. + core.setOutput('download_results', String(action === 'completed')); +} + +async function post({github, context, core}) { + const sizebotContext = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8')); + const body = readFileSync(COMMENT_PATH, 'utf8'); + + async function create() { + const {data} = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: sizebotContext.prNumber, + body, + }); + core.info(`Created ${data.html_url}`); + } + + if (sizebotContext.existingCommentId === null) { + await create(); + return; + } + + try { + const {data} = await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: sizebotContext.existingCommentId, + body, + }); + core.info(`Updated ${data.html_url}`); + } catch (error) { + if (error.status !== 404) { + throw error; + } + // Someone deleted the comment between resolving it and writing to it. + core.info('Existing comment is gone, posting a new one.'); + await create(); + } +} + +module.exports = {post, resolve}; diff --git a/scripts/sizebot/render-comment.js b/scripts/sizebot/render-comment.js new file mode 100644 index 000000000000..32eceef8d859 --- /dev/null +++ b/scripts/sizebot/render-comment.js @@ -0,0 +1,489 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +// Turns `sizebot-results.json` into the body of the sizebot pull request +// comment. Runs from a checkout of the default branch, never from the pull +// request branch, so the thresholds, the critical bundle list and the table +// itself cannot be influenced by the pull request being measured. Everything it +// reads out of the results file is therefore treated as untrusted input. +// +// Reads `sizebot-context.json` (written by the resolve step) and, when the build +// produced one, `sizebot-results.json`. Writes `sizebot-comment.md`, plus +// `sizebot-message.md` when the report is too large to fit in a comment and +// `sizebot-problem.txt` when the build configuration no longer matches this +// file's expectations. + +const {existsSync, readFileSync, writeFileSync} = require('fs'); + +// Results shapes this file knows how to read. `compare-sizes.js` on the pull +// request branch may be older or newer than this list. +const SUPPORTED_VERSIONS = new Set([1]); +const SUPPORTED_STATUSES = new Set(['ok', 'base-artifacts-unavailable']); + +const CRITICAL_THRESHOLD = 0.02; +const SIGNIFICANCE_THRESHOLD = 0.002; +const CRITICAL_ARTIFACT_PATHS = new Set([ + // We always report changes to these bundles, even if the change is + // insignificant or non-existent. + 'oss-stable/react-dom/cjs/react-dom.production.js', + 'oss-stable/react-dom/cjs/react-dom-client.production.js', + 'oss-experimental/react-dom/cjs/react-dom.production.js', + 'oss-experimental/react-dom/cjs/react-dom-client.production.js', + 'facebook-www/ReactDOM-prod.classic.js', + 'facebook-www/ReactDOM-prod.modern.js', +]); + +// GitHub comments are limited to 65536 characters. +const MAX_COMMENT_LENGTH = 65536; + +// Both the notice and the report are delimited so each can be rewritten without +// disturbing the other, and so a report can be read back out of a comment +// verbatim when a newer build supersedes it. Relying on "everything after the +// notice" instead would swallow the footer and append a second one every time. +const MARKER_PREFIX = ''; +const NOTICE_END = ''; +const REPORT_START = ''; +const REPORT_END = ''; + +const CONTEXT_PATH = 'sizebot-context.json'; +const RESULTS_PATH = 'sizebot-results.json'; +const COMMENT_PATH = 'sizebot-comment.md'; +const MESSAGE_PATH = 'sizebot-message.md'; +const PROBLEM_PATH = 'sizebot-problem.txt'; + +// Build artifact paths end up inside markdown link text and inside a URL, so +// anything that could break out of either is rejected rather than escaped. +const SAFE_ARTIFACT_PATH = /^[A-Za-z0-9_@./+-]+$/; + +function isSafeArtifactPath(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value.length < 512 && + SAFE_ARTIFACT_PATH.test(value) && + !value.includes('..') && + !value.startsWith('/') + ); +} + +function isSize(value) { + return value === null || (Number.isFinite(value) && value >= 0); +} + +function isSha(value) { + return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value); +} + +const kilobyteFormatter = new Intl.NumberFormat('en', { + style: 'unit', + unit: 'kilobyte', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function kbs(bytes) { + // An artifact that exists on only one side has no size on the other. The + // report has always shown that as 0.00 kB rather than an empty cell. + return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000); +} + +const percentFormatter = new Intl.NumberFormat('en', { + style: 'percent', + signDisplay: 'exceptZero', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function ratio(baseSize, headSize) { + if (baseSize === null) { + return Infinity; + } + if (headSize === null) { + return -1; + } + return (headSize - baseSize) / baseSize; +} + +function change(decimal) { + if (decimal === Infinity) { + return 'New file'; + } + if (decimal === -1) { + return 'Deleted'; + } + // Compare the magnitude, not the signed value. Testing `decimal < 0.0001` + // reported every size decrease as unchanged, which is why `signDisplay: + // 'exceptZero'` above never had a negative number to render. + if (Math.abs(decimal) < 0.0001) { + return '='; + } + return percentFormatter.format(decimal); +} + +const header = `| Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip | +| ---- | --- | ---- | ------- | -------- | --------- | ------------ |`; + +function row(result, baseSha, headSha) { + const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`; + const rowArr = [ + `| [${result.path}](${diffViewUrl})`, + `**${change(result.change)}**`, + `${kbs(result.baseSize)}`, + `${kbs(result.headSize)}`, + `${change(result.changeGzip)}`, + `${kbs(result.baseSizeGzip)}`, + `${kbs(result.headSizeGzip)}`, + ]; + return rowArr.join(' | '); +} + +function validateResults(raw) { + if (raw === null || typeof raw !== 'object') { + return {ok: false, reason: 'malformed'}; + } + // Checked before anything else so a shape this file cannot read produces a + // clear message instead of a misrendered table. + if (!SUPPORTED_VERSIONS.has(raw.version)) { + return {ok: false, reason: 'unsupported-version'}; + } + if (!SUPPORTED_STATUSES.has(raw.status)) { + return {ok: false, reason: 'malformed'}; + } + if (raw.status === 'base-artifacts-unavailable') { + return {ok: true, results: {status: raw.status}}; + } + if (!isSha(raw.baseSha) || !isSha(raw.headSha)) { + return {ok: false, reason: 'malformed'}; + } + if (!Array.isArray(raw.artifacts)) { + return {ok: false, reason: 'malformed'}; + } + for (const artifact of raw.artifacts) { + if (artifact === null || typeof artifact !== 'object') { + return {ok: false, reason: 'malformed'}; + } + if (!isSafeArtifactPath(artifact.path)) { + return {ok: false, reason: 'malformed'}; + } + if ( + !isSize(artifact.baseSize) || + !isSize(artifact.baseSizeGzip) || + !isSize(artifact.headSize) || + !isSize(artifact.headSizeGzip) + ) { + return {ok: false, reason: 'malformed'}; + } + if (artifact.baseSize === null && artifact.headSize === null) { + return {ok: false, reason: 'malformed'}; + } + } + return {ok: true, results: raw}; +} + +function renderTable(results) { + const {baseSha, headSha} = results; + + const resultsMap = new Map(); + for (const artifact of results.artifacts) { + resultsMap.set(artifact.path, { + ...artifact, + change: ratio(artifact.baseSize, artifact.headSize), + changeGzip: ratio(artifact.baseSizeGzip, artifact.headSizeGzip), + }); + } + + const sorted = Array.from(resultsMap.values()); + sorted.sort((a, b) => b.change - a.change); + + const criticalResults = []; + const missingCriticalPaths = []; + for (const artifactPath of CRITICAL_ARTIFACT_PATHS) { + const result = resultsMap.get(artifactPath); + if (result === undefined) { + missingCriticalPaths.push(artifactPath); + continue; + } + criticalResults.push(row(result, baseSha, headSha)); + } + + const significantResults = []; + for (const result of sorted) { + // If result exceeds critical threshold, add to top section. + if ( + (Math.abs(result.change) > CRITICAL_THRESHOLD || + // New file + result.change === Infinity || + // Deleted file + result.change === -1) && + // Skip critical artifacts. We added those earlier, in a fixed order. + !CRITICAL_ARTIFACT_PATHS.has(result.path) + ) { + criticalResults.push(row(result, baseSha, headSha)); + } + + // Do the same for results that exceed the significant threshold. These + // will go into the bottom, collapsed section. Intentionally including + // critical artifacts in this section, too. + if ( + Math.abs(result.change) > SIGNIFICANCE_THRESHOLD || + result.change === Infinity || + result.change === -1 + ) { + significantResults.push(row(result, baseSha, headSha)); + } + } + + const markdown = `Comparing: ${baseSha}...${headSha} + +## Critical size changes + +Includes critical production bundles, as well as any change greater than ${ + CRITICAL_THRESHOLD * 100 + }%: + +${header} +${criticalResults.join('\n')} + +## Significant size changes + +Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%: + +${ + significantResults.length > 0 + ? `
+Expand to show + +${header} +${significantResults.join('\n')} +
` + : '(No significant changes)' +}`; + + return {markdown, missingCriticalPaths}; +} + +function renderCompletedReport(context) { + const {runConclusion, runUrl, devtoolsOnly} = context; + + // The common outcome for a first-time contributor's pull request: the run is + // created but held until a maintainer approves it. + if (runConclusion === 'action_required') { + return { + markdown: `[The build for this commit](${runUrl}) needs maintainer approval before it can run, so there is no size report yet.`, + missingCriticalPaths: [], + }; + } + + if (runConclusion !== 'success') { + return { + markdown: `The build for this commit did not complete, so there is no size report. See [the workflow run](${runUrl}) for details.`, + missingCriticalPaths: [], + }; + } + + if (devtoolsOnly) { + return { + markdown: + 'No size report: this pull request only touches `packages/react-devtools`, which does not affect production bundle size.', + missingCriticalPaths: [], + }; + } + + if (!existsSync(RESULTS_PATH)) { + return { + markdown: `The build succeeded but produced no size results, so there is no size report. See [the workflow run](${runUrl}) for details.`, + missingCriticalPaths: [], + }; + } + + let raw; + try { + raw = JSON.parse(readFileSync(RESULTS_PATH, 'utf8')); + } catch { + raw = null; + } + + const validated = validateResults(raw); + if (!validated.ok) { + if (validated.reason === 'unsupported-version') { + return { + markdown: + 'This pull request produced a size report in a format this repository no longer reads. ' + + 'Merge the latest changes from the `main` branch to pick up the current one.', + missingCriticalPaths: [], + }; + } + return { + markdown: `The size results for this commit could not be read, so there is no size report. See [the workflow run](${runUrl}) for details.`, + missingCriticalPaths: [], + }; + } + + if (validated.results.status === 'base-artifacts-unavailable') { + return { + markdown: + "Failed to read build artifacts. It's possible a build configuration has changed upstream. " + + 'Try pulling the latest changes from the `main` branch.', + missingCriticalPaths: [], + }; + } + + return renderTable(validated.results); +} + +function renderNotice(context, reportHead) { + const {action, prHeadSha, runHeadSha, runStatus, runUrl} = context; + const lines = []; + + // One rule covers both the case where an older run's results arrive after the + // head moved, and the case where a new build supersedes a report already on + // display: the report simply is not about the pull request's current head. + if (reportHead !== null && reportHead !== prHeadSha) { + lines.push( + `These sizes are for ${reportHead}, which is no longer the head of this pull request.` + ); + if (action === 'requested') { + lines.push(`A build for ${runHeadSha} is in progress.`); + } + } else if (action === 'requested' && runStatus === 'waiting') { + lines.push( + `[The build for this commit](${runUrl}) is waiting for maintainer approval before it can run.` + ); + } + + if (lines.length === 0) { + return ''; + } + return lines.map(line => `> ${line}`).join('\n> \n'); +} + +function renderBody(context) { + let reportHead; + let report; + let missingCriticalPaths = []; + + if (context.action === 'requested') { + // Only a comment that names the commit it describes holds real numbers. A + // previous placeholder has body text too, but carrying that forward would + // pin the comment to a stale run link instead of refreshing it. + if ( + context.existingReportHead !== null && + context.existingReport !== null + ) { + // Keep the numbers from the previous build visible. The notice below + // explains that they describe an older commit. + reportHead = context.existingReportHead; + report = context.existingReport; + } else { + reportHead = null; + report = `A size report will appear here when [the build](${context.runUrl}) finishes.`; + } + } else { + reportHead = context.runHeadSha; + const rendered = renderCompletedReport(context); + report = rendered.markdown; + missingCriticalPaths = rendered.missingCriticalPaths; + } + + if (missingCriticalPaths.length > 0) { + report = + '> [!CAUTION]\n' + + '> These critical bundles are missing from the build. If that was an intentional\n' + + '> change to the build configuration, update `CRITICAL_ARTIFACT_PATHS` in\n' + + '> `scripts/sizebot/render-comment.js`:\n' + + missingCriticalPaths.map(p => `> - \`${p}\``).join('\n') + + '\n\n' + + report; + } + + const notice = renderNotice(context, reportHead); + const footerSha = reportHead === null ? context.runHeadSha : reportHead; + + function assemble(reportRegion) { + return `${MARKER_PREFIX} report-head=${ + reportHead === null ? 'none' : reportHead + } --> +${NOTICE_START} +${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END} +${REPORT_START} +${reportRegion} +${REPORT_END} + +Generated by sizebot against ${footerSha} +`; + } + + return { + body: assemble(report), + report, + assemble, + reportHead, + missingCriticalPaths, + }; +} + +// Reads the report region back out of a comment, so a completed report can be +// carried forward when a new build is requested for a newer commit. +function extractReport(body) { + const start = body.indexOf(REPORT_START); + const end = body.indexOf(REPORT_END); + if (start === -1 || end === -1 || end < start) { + return null; + } + const report = body.slice(start + REPORT_START.length, end).trim(); + return report === '' ? null : report; +} + +function parseReportHead(body) { + const match = + //.exec(body); + if (match === null || match[1] === 'none') { + return null; + } + return match[1]; +} + +function main() { + const context = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8')); + const {body, report, assemble, missingCriticalPaths} = renderBody(context); + + let comment = body; + if (body.length > MAX_COMMENT_LENGTH) { + // The link resolves because the artifact is uploaded to this same run, + // before the comment is posted. + writeFileSync(MESSAGE_PATH, report + '\n'); + comment = assemble( + `The size diff is too large to display in a single comment. [This workflow run](${context.commentRunUrl}) contains an artifact called \`sizebot-message.md\` with the full report.` + ); + } + writeFileSync(COMMENT_PATH, comment); + + if (missingCriticalPaths.length > 0) { + writeFileSync( + PROBLEM_PATH, + `Missing expected bundles:\n${missingCriticalPaths.join('\n')}\n` + ); + } + + process.stdout.write(comment); +} + +module.exports = { + MARKER_PREFIX, + extractReport, + parseReportHead, + renderBody, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/tasks/danger.js b/scripts/tasks/danger.js deleted file mode 100644 index d5aab0be414e..000000000000 --- a/scripts/tasks/danger.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and 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 path = require('path'); -const spawn = require('child_process').spawn; - -const extension = process.platform === 'win32' ? '.cmd' : ''; - -// sizebot public_repo token (this is publicly visible on purpose) -const token = 'ghp_UfuUaoow8veN3ZV1' + 'sGquTDgiVjRDmL2qLY1D'; -spawn( - path.join('node_modules', '.bin', 'danger-ci' + extension), - [ - '--id', - process.env.RELEASE_CHANNEL === 'experimental' ? 'experimental' : 'stable', - ], - { - // Allow colors to pass through - stdio: 'inherit', - env: { - ...process.env, - DANGER_GITHUB_API_TOKEN: token, - }, - } -).on('close', function (code) { - if (code !== 0) { - console.error('Danger failed'); - } else { - console.log('Danger passed'); - } - - process.exit(code); -}); diff --git a/yarn.lock b/yarn.lock index e047451cd08f..ce640aa8fda9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2840,36 +2840,6 @@ resolved "https://registry.yarnpkg.com/@fluent/syntax/-/syntax-0.19.0.tgz#43f882faba6908b0f1013f6a94e009d0dfbdcb77" integrity sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ== -"@gitbeaker/core@^21.7.0": - version "21.7.0" - resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-21.7.0.tgz#fcf7a12915d39f416e3f316d0a447a814179b8e5" - integrity sha512-cw72rE7tA27wc6JJe1WqeAj9v/6w0S7XJcEji+bRNjTlUfE1zgfW0Gf1mbGUi7F37SOABGCosQLfg9Qe63aIqA== - dependencies: - "@gitbeaker/requester-utils" "^21.7.0" - form-data "^3.0.0" - li "^1.3.0" - xcase "^2.0.1" - -"@gitbeaker/node@^21.3.0": - version "21.7.0" - resolved "https://registry.yarnpkg.com/@gitbeaker/node/-/node-21.7.0.tgz#2c19613f44ee497a8808c555abec614ebd2dfcad" - integrity sha512-OdM3VcTKYYqboOsnbiPcO0XimXXpYK4gTjARBZ6BWc+1LQXKmqo+OH6oUbyxOoaFu9hHECafIt3WZU3NM4sZTg== - dependencies: - "@gitbeaker/core" "^21.7.0" - "@gitbeaker/requester-utils" "^21.7.0" - form-data "^3.0.0" - got "^11.1.4" - xcase "^2.0.1" - -"@gitbeaker/requester-utils@^21.7.0": - version "21.7.0" - resolved "https://registry.yarnpkg.com/@gitbeaker/requester-utils/-/requester-utils-21.7.0.tgz#e9a9cfaf268d2a99eb7bbdc930943240a5f88878" - integrity sha512-eLTaVXlBnh8Qimj6QuMMA06mu/mLcJm3dy8nqhhn/Vm/D25sPrvpGwmbfFyvzj6QujPqtHvFfsCHtyZddL01qA== - dependencies: - form-data "^3.0.0" - query-string "^6.12.1" - xcase "^2.0.1" - "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -3409,7 +3379,7 @@ node-fetch "^2.6.7" universal-user-agent "^6.0.0" -"@octokit/rest@^16.43.0 || ^17.11.0 || ^18.12.0", "@octokit/rest@^18.12.0": +"@octokit/rest@^18.12.0": version "18.12.0" resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.12.0.tgz#f06bc4952fc87130308d810ca9d00e79f6988881" integrity sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q== @@ -5163,13 +5133,6 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async-retry@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.2.3.tgz#a6521f338358d322b1a0012b79030c6f411d1ce0" - integrity sha512-tfDb02Th6CE6pJUF2gjW5ZVjsgwlucVXOEQMvEX9JgSJMs9gAX+Nz3xRuJBKuUYjTSYORqvDBORdAQ3LU59g7Q== - dependencies: - retry "0.12.0" - async@^2.0.0, async@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -5926,11 +5889,6 @@ buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= -buffer-equal-constant-time@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" - integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== - buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" @@ -6208,7 +6166,7 @@ chalk@^1.0.0, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -6549,7 +6507,7 @@ colors@1.0.3: resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= -colors@1.4.0, colors@^1.1.2: +colors@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== @@ -6581,7 +6539,7 @@ commander@^10.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== -commander@^2.18.0, commander@^2.20.0, commander@^2.6.0, commander@^2.8.1: +commander@^2.20.0, commander@^2.6.0, commander@^2.8.1: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -6833,11 +6791,6 @@ core-js@^3.6.4: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" integrity sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw== -core-js@^3.8.2: - version "3.27.2" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.27.2.tgz#85b35453a424abdcacb97474797815f4d62ebbf7" - integrity sha512-9ashVQskuh5AZEZ1JdQWp1GqSoC1e1G87MzRqg2gIfVAQ7Qn9K+uFj8EcniUFA4P2NLZfV+TOlX1SzoKfo+s7w== - core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -7124,49 +7077,6 @@ currently-unhandled@^0.4.1: dependencies: array-find-index "^1.0.1" -danger@^11.2.3: - version "11.2.3" - resolved "https://registry.yarnpkg.com/danger/-/danger-11.2.3.tgz#2a0a6d3581478005d0f2abf5b3995a8409165067" - integrity sha512-NNDOUDZWCi1fqEicSWbnk8lOOoqY+vwekB8twUiknEyyvDDKypWEcUolq6SNg9Kd6HMqnX80K8U8nqzmDRC1QQ== - dependencies: - "@gitbeaker/node" "^21.3.0" - "@octokit/rest" "^18.12.0" - async-retry "1.2.3" - chalk "^2.3.0" - commander "^2.18.0" - core-js "^3.8.2" - debug "^4.1.1" - fast-json-patch "^3.0.0-1" - get-stdin "^6.0.0" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.1" - hyperlinker "^1.0.0" - json5 "^2.1.0" - jsonpointer "^5.0.0" - jsonwebtoken "^9.0.0" - lodash.find "^4.6.0" - lodash.includes "^4.3.0" - lodash.isobject "^3.0.2" - lodash.keys "^4.0.8" - lodash.mapvalues "^4.6.0" - lodash.memoize "^4.1.2" - memfs-or-file-map-to-github-branch "^1.2.1" - micromatch "^4.0.4" - node-cleanup "^2.1.2" - node-fetch "^2.6.7" - override-require "^1.1.1" - p-limit "^2.1.0" - parse-diff "^0.7.0" - parse-git-config "^2.0.3" - parse-github-url "^1.0.2" - parse-link-header "^2.0.0" - pinpoint "^1.1.0" - prettyjson "^1.2.1" - readline-sync "^1.4.9" - regenerator-runtime "^0.13.9" - require-from-string "^2.0.2" - supports-hyperlinks "^1.0.1" - data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" @@ -7665,13 +7575,6 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" - integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== - dependencies: - safe-buffer "^5.0.1" - ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -8583,13 +8486,6 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" -expand-tilde@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" - integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI= - dependencies: - homedir-polyfill "^1.0.1" - expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" @@ -8751,7 +8647,7 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-json-patch@3.1.1, fast-json-patch@^3.0.0-1: +fast-json-patch@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947" integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ== @@ -8985,11 +8881,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -filter-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" - integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== - finalhandler@1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" @@ -9195,15 +9086,6 @@ form-data-encoder@^2.1.2: resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -9250,11 +9132,6 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-exists-sync@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" - integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0= - fs-extra@11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" @@ -9446,15 +9323,6 @@ gifsicle@^5.0.0: execa "^5.0.0" logalot "^2.0.0" -git-config-path@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/git-config-path/-/git-config-path-1.0.1.tgz#6d33f7ed63db0d0e118131503bab3aca47d54664" - integrity sha1-bTP37WPbDQ4RgTFQO6s6ykfVRmQ= - dependencies: - extend-shallow "^2.0.1" - fs-exists-sync "^0.1.0" - homedir-polyfill "^1.0.0" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -9714,7 +9582,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -got@^11.1.4, got@^11.8.5: +got@^11.8.5: version "11.8.6" resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== @@ -9873,11 +9741,6 @@ has-bigints@^1.0.1: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== -has-flag@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" - integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE= - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -10025,7 +9888,7 @@ hermes-parser@^0.25.1: dependencies: hermes-estree "0.25.1" -homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1: +homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== @@ -10214,11 +10077,6 @@ human-signals@^4.3.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== -hyperlinker@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" - integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== - hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" @@ -10438,7 +10296,7 @@ ini@2.0.0, ini@~2.0.0: resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== -ini@^1.3.4, ini@^1.3.5, ini@~1.3.0, ini@~1.3.3: +ini@^1.3.4, ini@~1.3.0, ini@~1.3.3: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -11738,13 +11596,6 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" - integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== - dependencies: - minimist "^1.2.0" - json5@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" @@ -11778,21 +11629,6 @@ jsonify@~0.0.0: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= -jsonpointer@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" - integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== - -jsonwebtoken@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d" - integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== - dependencies: - jws "^3.2.2" - lodash "^4.17.21" - ms "^2.1.1" - semver "^7.3.8" - jsx-ast-utils@^1.3.4: version "1.4.1" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz#3867213e8dd79bf1e8f2300c0cfc1efb182c0df1" @@ -11813,23 +11649,6 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== -jwa@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" - integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== - dependencies: - buffer-equal-constant-time "^1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - -jws@^3.2.2: - version "3.2.3" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1" - integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g== - dependencies: - jwa "^1.4.2" - safe-buffer "^5.0.1" - keyv@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" @@ -11948,11 +11767,6 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -li@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" - integrity sha1-IsWbyu+qmo7zWc91l4TkvxBq6hs= - lie@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" @@ -12066,11 +11880,6 @@ lodash.difference@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw= -lodash.find@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-4.6.0.tgz#cb0704d47ab71789ffa0de8b97dd926fb88b13b1" - integrity sha1-ywcE1Hq3F4n/oN6Ll92Sb7iLE7E= - lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" @@ -12081,11 +11890,6 @@ lodash.hasin@4.5.2: resolved "https://registry.yarnpkg.com/lodash.hasin/-/lodash.hasin-4.5.2.tgz#f91e352378d21ef7090b9e7687c2ca35c5b4d52a" integrity sha1-+R41I3jSHvcJC552h8LKNcW01So= -lodash.includes@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" - integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= - lodash.isempty@4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" @@ -12096,31 +11900,11 @@ lodash.isnil@4.0.0: resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c" integrity sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw= -lodash.isobject@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" - integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0= - lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= -lodash.keys@^4.0.8: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-4.2.0.tgz#a08602ac12e4fb83f91fc1fb7a360a4d9ba35205" - integrity sha1-oIYCrBLk+4P5H8H7ejYKTZujUgU= - -lodash.mapvalues@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" - integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw= - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= - lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -12384,13 +12168,6 @@ mem@^5.0.0: mimic-fn "^2.1.0" p-is-promise "^2.1.0" -memfs-or-file-map-to-github-branch@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/memfs-or-file-map-to-github-branch/-/memfs-or-file-map-to-github-branch-1.2.1.tgz#fdb9a85408262316a9bd5567409bf89be7d72f96" - integrity sha512-I/hQzJ2a/pCGR8fkSQ9l5Yx+FQ4e7X6blNHyWBm2ojeFLT3GVzGkTj7xnyWpdclrr7Nq4dmx3xrvu70m3ypzAQ== - dependencies: - "@octokit/rest" "^16.43.0 || ^17.11.0 || ^18.12.0" - memfs@^3.4.3: version "3.5.1" resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.5.1.tgz#f0cd1e2bfaef58f6fe09bfb9c2288f07fea099ec" @@ -12756,11 +12533,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-cleanup@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c" - integrity sha1-esGavSl+Caf3KnFUXZUbUX5N3iw= - node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -13248,11 +13020,6 @@ osenv@0.0.3: resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.0.3.tgz#cd6ad8ddb290915ad9e22765576025d411f29cb6" integrity sha1-zWrY3bKQkVrZ4idlV2Al1BHynLY= -override-require@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/override-require/-/override-require-1.1.1.tgz#6ae22fadeb1f850ffb0cf4c20ff7b87e5eb650df" - integrity sha1-auIvresfhQ/7DPTCD/e4fl62UN8= - p-cancelable@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" @@ -13326,13 +13093,6 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^2.1.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e" - integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ== - dependencies: - p-try "^2.0.0" - p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -13454,11 +13214,6 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-diff@^0.7.0: - version "0.7.1" - resolved "https://registry.yarnpkg.com/parse-diff/-/parse-diff-0.7.1.tgz#9b7a2451c3725baf2c87c831ba192d40ee2237d4" - integrity sha512-1j3l8IKcy4yRK2W4o9EYvJLSzpAVwz4DXqCewYyx2vEwk2gcf3DBPqc8Fj4XV3K33OYJ08A8fWwyu/ykD/HUSg== - parse-filepath@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" @@ -13468,20 +13223,6 @@ parse-filepath@^1.0.2: map-cache "^0.2.0" path-root "^0.1.1" -parse-git-config@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-2.0.3.tgz#6fb840d4a956e28b971c97b33a5deb73a6d5b6bb" - integrity sha512-Js7ueMZOVSZ3tP8C7E3KZiHv6QQl7lnJ+OkbxoaFazzSa2KyEHqApfGbU3XboUgUnq4ZuUmskUpYKTNx01fm5A== - dependencies: - expand-tilde "^2.0.2" - git-config-path "^1.0.1" - ini "^1.3.5" - -parse-github-url@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-github-url/-/parse-github-url-1.0.2.tgz#242d3b65cbcdda14bb50439e3242acf6971db395" - integrity sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw== - parse-json@7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-7.1.1.tgz#68f7e6f0edf88c54ab14c00eb700b753b14e2120" @@ -13510,13 +13251,6 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse-link-header@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-2.0.0.tgz#949353e284f8aa01f2ac857a98f692b57733f6b7" - integrity sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw== - dependencies: - xtend "~4.0.1" - parse-node-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -13766,11 +13500,6 @@ pino@8.20.0: sonic-boom "^3.7.0" thread-stream "^2.0.0" -pinpoint@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pinpoint/-/pinpoint-1.1.0.tgz#0cf7757a6977f1bf7f6a32207b709e377388e874" - integrity sha1-DPd1eml38b9/ajIge3CeN3OI6HQ= - pirates@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9" @@ -14008,14 +13737,6 @@ pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -prettyjson@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prettyjson/-/prettyjson-1.2.1.tgz#fcffab41d19cab4dfae5e575e64246619b12d289" - integrity sha1-/P+rQdGcq0365eV15kJGYZsS0ok= - dependencies: - colors "^1.1.2" - minimist "^1.2.0" - process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -14198,16 +13919,6 @@ query-string@^5.0.1: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -query-string@^6.12.1: - version "6.14.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.14.1.tgz#7ac2dca46da7f309449ba0f86b1fd28255b0c86a" - integrity sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw== - dependencies: - decode-uri-component "^0.2.0" - filter-obj "^1.1.0" - split-on-first "^1.0.0" - strict-uri-encode "^2.0.0" - querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -14517,11 +14228,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -readline-sync@^1.4.9: - version "1.4.10" - resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b" - integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw== - real-require@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" @@ -14578,11 +14284,6 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== -regenerator-runtime@^0.13.9: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" @@ -14872,11 +14573,6 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -retry@0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= - retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -15234,7 +14930,7 @@ semver@^7.1.1: resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.1.tgz#29104598a197d6cbe4733eeecbe968f7b43a9667" integrity sha512-WfuG+fl6eh3eZ2qAf6goB7nhiCd7NPXhmyFxigB/TOkQyeLP8w8GsVehvtGNtnNmyboz4TgeK40B1Kbql/8c5A== -semver@^7.2.1, semver@^7.3.8: +semver@^7.2.1: version "7.3.8" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== @@ -15700,11 +15396,6 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" -split-on-first@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" - integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== - split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -15806,11 +15497,6 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= -strict-uri-encode@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" - integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= - string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -16088,7 +15774,7 @@ supports-color@^2.0.0: resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.4.0: +supports-color@^5.3.0, supports-color@^5.4.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -16109,14 +15795,6 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" -supports-hyperlinks@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-1.0.1.tgz#71daedf36cc1060ac5100c351bb3da48c29c0ef7" - integrity sha512-HHi5kVSefKaJkGYXbDuKbUGRVxqnWGn3J2e39CYcNJEfWciGq2zYtOhXLTlvrOZW1QU7VX67w7fMmWafHX9Pfw== - dependencies: - has-flag "^2.0.0" - supports-color "^5.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -17532,11 +17210,6 @@ ws@^7: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -xcase@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" - integrity sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw== - xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" From dc631ef588bd10a63eeb9a1c4c133117755c83d7 Mon Sep 17 00:00:00 2001 From: dan Date: Sun, 23 Aug 2026 18:46:34 +0100 Subject: [PATCH 2/2] [Flight] Outline and dedupe repeated strings (#37147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flight has two ways to write a string: 1. Small ones are inlined into the JSON model. 2. Large ones (>= 1024 chars) are outlined into a binary text row so they don't get double-encoded and double-parsed. Neither is ever deduplicated. That's most visible in client reference metadata, where a route repeats the same bundler chunk URLs across every client reference (for example, https://github.com/vercel/next.js/issues/95559). **This PR adds a dedupe map for strings inside import metadata.** A string is written once into its own row, and every occurrence is a reference to it. ## How it works When we're about to write a string in import metadata at least as long as the threshold, we look it up in the request's map: - **If it's not in the map:** Emit a row containing the string, store that row's reference in the map, and write the reference here. - **If it is:** Write the reference. So every string goes on the wire once, and every occurrence costs a few bytes. An earlier version waited for the second occurrence before outlining, which is the right default for arbitrary strings where most never repeat (it's what #27537 does for objects). Import metadata is the opposite case: a chunk is listed by every client module that lives in it, so a chunk name that appears once is the exception. In the bench app's three routes, every chunk string at least 16 characters long appears more than 20 times and none appears once. Outlining on first sight saves the inline copy, and a string that never repeats costs 7 bytes more than inlining it. Import metadata needs its own map and its own queue. The client resolves a client reference as soon as it parses the import row, and import chunks flush ahead of model rows, so the string row has to be in the same queue to arrive first. The client needs no protocol changes. It already resolves `$N` references, and a row holding a string resolves to that string. It does get a check that import metadata never blocks on a row that hasn't arrived, which is the other end of the queue ordering above, and `getOutlinedModel` stops allocating a path array for references without one. Model strings are left alone. An earlier version of this PR deduped them too, but we're not going to do this now per review (maybe in a follow-up). Metadata on the debug channel is also left alone: it's a separate serialization path, and deduping across the two would make the main payload depend on whether a debug channel is attached. ## Threshold The trigger is 16, low compared to what a model-side threshold would want, because import metadata is repetitive but its parts are short. How much this saves depends on how many client references share a chunk list, so measuring one string on its own is misleading. For a chunk path of realistic length today: | references sharing the chunk | before | after | |---|---|---| | 1 | 80 B | 87 B | | 2 | 160 B | 122 B | | 3 | 240 B | 157 B | | 5 | 401 B | 228 B | | 10 | 813 B | 416 B | | 40 | 3273 B | 1526 B | | 80 | 6594 B | 3047 B | (Import and string rows only.) It costs 7 bytes at 1 reference and wins from 2. Chunk paths in this app are 47 characters, so a threshold of 48 or higher saves nothing at all here. That's why it's 16: picking a number just under one bundler's path length gives you something that quietly stops working on the next bundler. The map is bounded by the combined length of the strings it holds, 32 KiB. Once the budget is spent, new strings are written inline every time while strings already outlined keep deduping. That makes the savings depend on the order strings are first seen: a shared chunk URL first encountered after 32 KiB of unique module ids won't be deduped. That's main's behavior, so it's a missed win rather than a regression, but a manifest-heavy dev route could hit it. ## Byte measurements Three routes of a Next.js app, serial requests: | route | Flight | document | document (gzip) | |---|---|---|---| | `/dashboard` | −48.4% (710.1 → 366.5 KB) | −34.3% | −7.8% | | `/docs` | −5.8% (555.2 → 523.1 KB) | −5.0% | −0.7% | | `/blog` | −5.4% (878.8 → 831.7 KB) | −4.4% | −1.7% | The difference between the routes is how many client references each one has. On `/dashboard` the import rows shrink from 388.0 KB to about 32 KB with the row *count* unchanged at 114, because every client reference repeats the same 49 chunk URLs. gzip already collapses repeated strings, so −48.4% raw is only −7.8% compressed. The bytes still have to be escaped, encoded and copied before they reach the compressor, which is where most of the speedup below comes from. ## Speed measurements Benchmarked end-to-end through a Next.js app on Vercel Sandbox VMs (x86 Xeon), 16 boots, paired ABBA within each boot, boot as the unit of replication. Base is the merge-base with main, `eafeac09`; candidate is the current head, `e0b4614c`. | cell | effect | 95% CI | p | |---|---|---|---| | `/dashboard` serial req/s | **+16.9%** | ±1.7 | <0.0001 | | `/dashboard` serial p95 latency | −18.2% | ±2.4 | <0.0001 | | `/dashboard` serial TTFB | −23.2% | ±1.2 | <0.0001 | | `/dashboard` under load req/s | **+17.0%** | ±3.4 | <0.0001 | | `/dashboard` under load median latency | −13.9% | ±2.5 | <0.0001 | | `/docs` serial req/s | +3.0% | ±1.4 | 0.0003 | | `/docs` serial TTFB | −3.1% | ±1.0 | <0.0001 | | `/blog` serial req/s | +2.4% | ±1.0 | 0.0001 | | `/blog` serial median latency | −2.4% | ±0.7 | <0.0001 | No detected difference: `/blog` and `/docs` under load (p=0.13–0.56). All 16 boots are positive on both `/dashboard` cells. The `/dashboard` headline has now been measured in four separate 16-boot runs across four heads of this branch and is p<0.0001 in each; the small routes cleared p<0.01 only on this head, after the serializer change below, having sat at p=0.02–0.06 on the three earlier heads. The previous head, `4569e1d6`, which outlined on the second occurrence rather than the first, measured +14.9% ±2.3 on `/dashboard` serial req/s and −17.3% ±5.7 on TTFB against the same base. Those intervals overlap the ones above, so the switch is not a measurable speedup on its own; the bytes it saves are about 1% of the payload. In a real browser on `/dashboard` (measured on an earlier commit of this branch, `aed4d523`), hydration is −2.8% ±1.3 (p=0.0003) / −2.4% ±0.9 (p=0.0001) and LCP is −5.3% ±2.0 (p<0.0001) / −3.6% ±2.6 (p=0.009). Client navigation is under the noise floor in both. ### Where the time goes 32 CPU profiles, taken after the timed runs with an identical request count in both arms, so absolute sampled milliseconds are comparable. One pass per boot, no replication statistics — directional, not a claim. These profiles are from `aed4d523`. The current head also walks the metadata into a copy before a plain `stringify`, after a detour through a `stringify` replacer that measured 2.3× slower in isolation (a replacer function takes V8 off its fast path for the whole call); the `transformImportMetadata` frame below is a fair proxy for the current cost. Cheaper: | base | candidate | frame | | ---: | ---: | --- | | 23.6 s | 5.7 s | ReactDOM `preinitScript` | | 24.9 s | 10.6 s | `serializeClientReference` | | 81.8 s | 67.3 s | `utf8Write` | | 93.9 s | 80.8 s | `createFromString` | | 50.7 s | 39.2 s | Next's `htmlEscapeJsonString` | More expensive: | base | candidate | frame | | ---: | ---: | --- | | 0 | 9.3 s | `transformImportMetadata` | | 2.0 s | 10.3 s | `getOutlinedModel` (SSR-side Flight client) | | 7.7 s | 11.7 s | `parseModelString` | | 154.8 s | 158.2 s | `resolveModelToJSON` | About +31 s of new work against −79 s inside the runtime bundle and −45 s in node's buffer and string layer. `getOutlinedModel` resolving references is the mechanism working, not a warning sign. Next.js runs a Flight client on the server to read its own payload, and a `/dashboard` payload goes from 0 references inside import rows to 4964, so a frame that barely ran before now runs once per reference. Each call is a lookup on a row that has already been initialized: the string row goes into the import queue ahead of the import row that reads it, so it has always arrived and nothing blocks. `parseModelString` grows for the same reason. `preinitScript` doesn't get cheaper from writing fewer bytes. It does two dictionary lookups keyed by the chunk URL per call, and the call count and argument values are unchanged — the resolved models are identical. What changes is string identity: in the base build every one of the 5013 chunk-URL occurrences is a fresh string out of `JSON.parse` whose hash has to be computed before the lookup, and with dedupe the 49 distinct URLs are parsed once and every reference yields the same string, so V8's cached hash makes the repeat lookups nearly free. Some of the `htmlEscapeJsonString` and buffer-layer drops have the same cause. ### React-level CPU in isolation The e2e numbers above include everything downstream of React (escaping, encoding, compression, the SSR client). To see React's own serialization cost, 114 import rows of dashboard-shaped metadata (49 shared 74-character chunk names per row) were rendered against one request on the production bundles with a no-op destination, arms interleaved, median of 5 rounds × 200: | | main | this PR | |---|---|---| | 49 names shared by all rows | 0.502 ms | **0.322 ms** (−36%) | | 5586 unique names, nothing to dedupe | 0.477 ms | 0.771 ms (+62%) | The second row is the worst case for this change, a manifest where every chunk name appears once. It costs about 40 ns per unique string, plus about 130 ns for each row the budget lets it outline, against a payload that is otherwise unchanged. The metadata is serialized by copying it with the strings already replaced and then calling plain `JSON.stringify`; a `stringify` replacer function would keep V8 off its fast path for the whole call (measured 2.3× slower than plain in isolation, even writing a sixth of the bytes). The copy covers plain JSON only and falls back to the replacer for anything else (`toJSON`, class instances, keys that exist on `Object.prototype`, depth over four, which is how cycles end up throwing stringify's own error). Equivalence of the two paths was checked by a harness that runs both on identical requests and compares the JSON and the resulting request state: 2,656,142 cases, including exhaustive enumeration of small trees over adversarial atoms, 100k seeded random values, and the cases from two independent adversarial reviews — 0 divergences outside four stated assumptions that no bundler manifest violates (no Proxies, no index accessors polluted onto `Array.prototype`, no primitive wrappers with a swapped prototype, side-effect-free property access). ## Cost where there's nothing to dedupe React's own `flight-ssr-bench` fixture has about ten client modules and no repeated chunk paths, so the dedupe never fires and the change can only cost. It costs a little, if anything. Over 16 boots at `aed4d523` the Flight+Fizz Node sync variant was +0.9% ±0.7 on median inject time (p=0.008), worse on 14 of 16 boots. On the current head the four Flight+Fizz inject cells are between +0.4% and +0.8% on the median, none below p=0.07; across all 88 fixture metrics (Fizz and Flight+Fizz, Node and Edge, sync and async, inject and HTTP at c=1/c=10) nothing reaches p<0.01 and `heapMb` is flat to ±0.1%. So the no-dedupe cost is somewhere around half a percent of inject time on this fixture, at the edge of what it can resolve. I couldn't localize it past that. It isn't the per-request `Map`, which is about 22 ns against a 14 ms render, and it isn't allocation — `gcMs` and `heapMb` are flat. Using the Fizz-only variants as a within-boot control, since nothing in `ReactFlightServer.js` can reach them, the Flight-specific residual on that cell is +0.8% ±0.5 and the other three variants scatter around zero (+0.4%, +0.1%, −0.2%). A build that re-inlines `escapeStringValue` back into the string branch, which is the only change here that runs for every string in the model rather than only for import metadata, doesn't recover it either (+0.2% ±0.3 on the same cell, another 16 boots). So this looks like code layout rather than a specific added operation, and it's near the resolution limit of the fixture.
Verification - Both arms' payloads for `/dashboard` were parsed and their `$`-references resolved recursively, then deep-compared: the resolved models are identical. The 49 extra model rows are exactly the 49 distinct chunk URLs. All 114 import rows match after resolution. - Arms fingerprint distinctly (`a898f40a7bbd` vs `87fb4b7ba15e`), so the two builds are genuinely different. - Build fingerprints differ between arms (`04440a11435d` vs `43d09027ce58`) and the arm version strings carry the expected shas. - Per-boot deltas are printed by the harness; on `/dashboard` serial req/s all 16 boots are positive (range +11.2% to +22.9%). - The bench fixture sets a deployment id, so every chunk URL carries a `?dpl=` query param that exactly doubles its length (74 chars vs 37). An app without one would see roughly half the absolute byte saving on this route. The CPU wins that come from string identity rather than byte count should degrade less than proportionally, but that wasn't measured. - Not measured: payloads that exceed the 32 KiB tracking budget, and whether 16 is optimal rather than merely low enough.
--------- Co-authored-by: Claude Opus 5 --- .../react-client/src/ReactFlightClient.js | 30 +- .../src/__tests__/ReactFlightDOMEdge-test.js | 287 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 197 +++++++++++- scripts/error-codes/codes.json | 3 +- 4 files changed, 505 insertions(+), 12 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 12ef52669ba4..7ed683b110bd 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2173,6 +2173,10 @@ function transferReferencedDebugInfo( } } +// Most references have no path, so they can all share the same empty array. +// It's never mutated because only paths with entries get spliced in place. +const EMPTY_REFERENCE_PATH: Array = []; + function getOutlinedModel( response: Response, reference: string, @@ -2180,8 +2184,10 @@ function getOutlinedModel( key: string, map: (response: Response, model: any, parentObject: Object, key: string) => T, ): T { - const path = reference.split(':'); - const id = parseInt(path[0], 16); + // parseInt stops at the ':' so we only need to split when there's a path. + const id = parseInt(reference, 16); + const path = + reference.indexOf(':') === -1 ? EMPTY_REFERENCE_PATH : reference.split(':'); const chunk = getChunk(response, id); if (enableProfilerTimer && enableComponentPerformanceTrack) { if (initializingChunk !== null && isArray(initializingChunk._children)) { @@ -3246,10 +3252,22 @@ function resolveModule( ): void { const chunks = response._chunks; const chunk = chunks.get(id); - const clientReferenceMetadata: ClientReferenceMetadata = parseModel( - response, - model, - ); + const prevHandler = initializingHandler; + initializingHandler = null; + let clientReferenceMetadata: ClientReferenceMetadata; + try { + clientReferenceMetadata = parseModel(response, model); + if (initializingHandler !== null) { + // We resolve the client reference below and have nothing to wait on, + // so the metadata can't reference a row that hasn't arrived. + throw new Error( + 'A client reference was blocked on a row that has not been received yet. ' + + 'This is a bug in React.', + ); + } + } finally { + initializingHandler = prevHandler; + } const clientReference = resolveClientReference<$FlowFixMe>( response._bundlerConfig, clientReferenceMetadata, diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 0c1f9add5b44..2e846579db36 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1107,6 +1107,233 @@ describe('ReactFlightDOMEdge', () => { expect(items[5]).toEqual(items[10]); }); + function clientComponent(name, chunkFilename) { + return clientExports( + function Client() { + return {name}; + }, + 'chunk-' + name, + chunkFilename, + Promise.resolve(), + ); + } + + async function renderClients(chunkFilenames) { + const Clients = chunkFilenames.map(chunkFilename => + clientComponent('Client', chunkFilename), + ); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( +
+ {Clients.map((Client, i) => ( + + ))} +
, + webpackMap, + ), + ); + const [stream1, stream2] = passThrough(stream).tee(); + const payload = await readResult(stream1); + const model = await ReactServerDOMClient.createFromReadableStream(stream2, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(model), + ); + expect(await readResult(ssrStream)).toBe( + '
' + 'Client'.repeat(Clients.length) + '
', + ); + return payload; + } + + it('should dedupe strings inside client reference metadata', async () => { + // Bundlers repeat the same chunk in the metadata of every client reference + // that needs it. + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const shared = await renderClients(new Array(10).fill(chunk)); + // The same length, so sharing is the only difference between the two. + const distinct = await renderClients( + Array.from( + {length: 10}, + (_, i) => 'unique/hashed-chunk-' + ('' + i).padStart(16, '0') + '.js', + ), + ); + + // However many references there are, the chunk goes on the wire once, as a + // row that every import row points at. + expect(shared.split(chunk).length - 1).toBe(1); + expect(distinct.length - shared.length).toBeGreaterThan(8 * chunk.length); + + // The client resolves a client reference while parsing its row, so the + // outlined copy has to arrive before every row that points at it. The + // chunk id is too short to be outlined, so it counts the rows. + const beforeOutlinedCopy = shared.slice(0, shared.indexOf(chunk)); + expect(beforeOutlinedCopy).not.toContain('chunk-Client'); + }); + + it('should escape strings inside client reference metadata', async () => { + // A leading $ has to be escaped whether the string gets outlined or not. + const outlinedChunk = '$shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const inlineChunk = '$chunk.js'; + const outlined = await renderClients(new Array(3).fill(outlinedChunk)); + const inline = await renderClients(new Array(3).fill(inlineChunk)); + + expect(outlined.split('$' + outlinedChunk).length - 1).toBe(1); + expect(inline.split('$' + inlineChunk).length - 1).toBe(3); + }); + + it('should not dedupe import strings below the size limit', async () => { + // A short string costs more to reference than to repeat. + const shortChunk = 'abc/chunk-15.js'; + const longChunk = 'abcd/chunk-16.js'; + const short = await renderClients(new Array(10).fill(shortChunk)); + const long = await renderClients(new Array(10).fill(longChunk)); + + expect(short.split(shortChunk).length - 1).toBe(10); + expect(long.split(longChunk).length - 1).toBe(1); + // The longer chunk is the one that produces the smaller payload. + expect(long.length).toBeLessThan(short.length); + }); + + it('should stop tracking new import strings once the budget is spent', async () => { + // Every chunk is outlined the first time it's seen, so chunks that never + // repeat spend budget too. 32 fillers of 1 KiB fill the 32 KiB budget. + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const repeats = new Array(10).fill(chunk); + const filler = (count, length) => + Array.from({length: count}, (_, i) => + ('filler/chunk-' + i + '-').padEnd(length - 3, 'x').concat('.js'), + ); + const fitsInTheRest = await renderClients(filler(31, 1024).concat(repeats)); + const findsItSpent = await renderClients(filler(32, 1024).concat(repeats)); + + expect(fitsInTheRest.split(chunk).length - 1).toBe(1); + expect(findsItSpent.split(chunk).length - 1).toBe(10); + + // A string outlined before the budget is spent keeps deduping after. + const lastFiller = filler(1, 32768 - 31 * 1024 - chunk.length); + const trackedBefore = await renderClients( + [chunk].concat(filler(31, 1024), lastFiller, repeats), + ); + + expect(trackedBefore.split(chunk).length - 1).toBe(1); + + const bigChunk = 'path/to/' + 'a'.repeat(40000) + '.js'; + const big = await renderClients(new Array(3).fill(bigChunk)); + + expect(big.split(bigChunk).length - 1).toBe(3); + }); + + it('should dedupe import strings produced by toJSON', async () => { + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const payload = await renderClients( + Array.from({length: 3}, () => ({ + toJSON() { + return chunk; + }, + })), + ); + + expect(payload.split(chunk).length - 1).toBe(1); + }); + + it('should error on circular client reference metadata', async () => { + const circular = []; + circular.push(circular); + const Client = clientComponent('Client', circular); + + const errors = []; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap, { + onError(error) { + errors.push(error.message); + }, + }), + ); + await readResult(stream); + + expect(errors).toEqual([ + expect.stringContaining('Converting circular structure to JSON'), + ]); + }); + + it('should not dedupe strings in the model', async () => { + // Only import metadata is deduped. Keying a map on arbitrary model strings + // would hold them in memory for the rest of the request. + const text = 'a repeated model string well past the import threshold'; + const model = new Array(10).fill(text); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model), + ); + const [stream1, stream2] = passThrough(stream).tee(); + + const payload = await readResult(stream1); + expect(payload.split(text).length - 1).toBe(10); + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + expect(result).toEqual(model); + }); + + // @gate __DEV__ + it('should not dedupe import metadata on the debug channel', async () => { + // The debug channel is a separate transport, so a row it emits can't be + // referenced from the main stream and vice versa. + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const A = clientComponent('Client', chunk); + const B = clientComponent('Client', chunk); + const C = clientComponent('Client', chunk); + + function Server({a, b, c}) { + return ReactServer.createElement('div', null, a, b, c); + } + + let debugContent = ''; + const debugChannel = { + writable: new WritableStream({ + write(value) { + debugContent += Buffer.from(value).toString('utf8'); + }, + }), + }; + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + // We can't use JSX here because it'll use the Client React. + ReactServer.createElement(Server, { + a: ReactServer.createElement(A), + b: ReactServer.createElement(B), + c: ReactServer.createElement(C), + }), + webpackMap, + {debugChannel}, + ), + ); + const payload = await readResult(stream); + + // The main stream dedupes as usual. + expect(payload.split(chunk).length - 1).toBe(1); + + // The debug channel can't point at that row, so every import row it writes + // spells the chunk out. The chunk id is too short to be outlined, so it + // counts those rows. + expect(debugContent).toContain('chunk-Client'); + expect(debugContent.split(chunk).length).toBe( + debugContent.split('chunk-Client').length, + ); + }); + it('warns if passing a this argument to bind() of a server reference', async () => { const ServerModule = serverExports({ greet: function () {}, @@ -2370,6 +2597,66 @@ describe('ReactFlightDOMEdge', () => { ); }); + async function renderThroughDebugChannel(chunkFilename) { + const Client = clientComponent('Client', chunkFilename); + // The client reference shows up in the owner's props on the debug channel. + function Server({component}) { + return ReactServer.createElement(component, null); + } + + let debugReadableStreamController; + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + ReactServer.createElement(Server, {component: Client}), + webpackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + const response = ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: {moduleMap: null, moduleLoading: null}, + debugChannel: {readable: debugReadableStream}, + }); + + function ClientRoot() { + return use(response); + } + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); + return readResult(ssrStream); + } + + it('can resolve a client reference while debug info is still blocked', async () => { + const result = await renderThroughDebugChannel('path/to/chunk.js'); + + expect(result).toBe('Client'); + }); + + it('should escape strings in import metadata on the debug channel', async () => { + const result = await renderThroughDebugChannel('$path/to/chunk.js'); + + expect(result).toBe('Client'); + }); + it('should properly resolve with deduped objects', async () => { const obj = {foo: 'hi'}; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index d9177cd27645..e3893a75de3b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -616,6 +616,9 @@ export type Request = { writtenClientReferences: Map, writtenServerReferences: Map, number>, writtenObjects: WeakMap, + writtenImportStrings: Map, + // The combined length of the keys in writtenImportStrings. + writtenImportStringsSize: number, temporaryReferences: void | TemporaryReferenceSet, identifierPrefix: string, identifierCount: number, @@ -741,6 +744,8 @@ function RequestInstance( this.writtenClientReferences = new Map(); this.writtenServerReferences = new Map(); this.writtenObjects = new WeakMap(); + this.writtenImportStrings = new Map(); + this.writtenImportStringsSize = 0; this.temporaryReferences = temporaryReferences; this.identifierPrefix = identifierPrefix || ''; this.identifierCount = 1; @@ -2250,6 +2255,16 @@ let canEmitDebugInfo: boolean = false; let serializedSize = 0; const MAX_ROW_SIZE = 3200; +// Bundler metadata repeats the same chunk URLs across every client reference of +// a route, so strings in it at least this long get outlined and deduplicated +// when they repeat. The threshold is bounded away from zero because outlining +// something as short as an export name costs more than copying it. +const MIN_DEDUPLICATED_IMPORT_STRING_LENGTH = 16; + +// Tracked strings are retained for the rest of the request, so their combined +// length is capped. +const MAX_DEDUPLICATED_IMPORT_STRINGS_SIZE = 32768; + function deferTask(request: Request, task: Task): ReactJSONValue { // Like outlineTask but instead the item is scheduled to be serialized // after its parent in the stream. @@ -3172,9 +3187,15 @@ function serializeClientReference( try { const clientReferenceMetadata: ClientReferenceMetadata = resolveClientReferenceMetadata(request.bundlerConfig, clientReference); + // Stringify before claiming a chunk id so a throw can't leave it pending. + const json = stringifyImportMetadata( + request, + clientReferenceMetadata, + false, + ); request.pendingChunks++; const importId = request.nextChunkId++; - emitImportChunk(request, importId, clientReferenceMetadata, false); + emitImportChunk(request, importId, json, false); writtenClientReferences.set(clientReferenceKey, importId); if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') { // If we're encoding the "type" of an element, we can refer @@ -3222,9 +3243,14 @@ function serializeDebugClientReference( try { const clientReferenceMetadata: ClientReferenceMetadata = resolveClientReferenceMetadata(request.bundlerConfig, clientReference); + const json = stringifyImportMetadata( + request, + clientReferenceMetadata, + true, + ); request.pendingDebugChunks++; const importId = request.nextChunkId++; - emitImportChunk(request, importId, clientReferenceMetadata, true); + emitImportChunk(request, importId, json, true); if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') { // If we're encoding the "type" of an element, we can refer // to that by a lazy reference instead of directly since React @@ -3594,6 +3620,41 @@ function escapeStringValue(value: string): string { } } +function serializeImportString(request: Request, value: string): string { + // No maximum length because import strings are short and repeat often. + // Deduping model strings too would need one to skip very long strings. + if (value.length < MIN_DEDUPLICATED_IMPORT_STRING_LENGTH) { + return escapeStringValue(value); + } + const writtenStrings = request.writtenImportStrings; + const existing = writtenStrings.get(value); + if (existing !== undefined) { + return existing; + } + const size = request.writtenImportStringsSize + value.length; + if (size > MAX_DEDUPLICATED_IMPORT_STRINGS_SIZE) { + // The map is full. Strings already outlined keep deduping; new ones are + // written out every time. + return escapeStringValue(value); + } + request.writtenImportStringsSize = size; + // Chunk names are almost always shared, so the first occurrence is outlined + // right away instead of waiting for a repeat. + request.pendingChunks++; + const outlinedId = request.nextChunkId++; + // $FlowFixMe[incompatible-type] stringify can return null + const json: string = stringify(escapeStringValue(value)); + // The client reads import metadata synchronously, so this row has to have + // been written by the time the referencing row arrives. Import chunks are + // flushed ahead of regular ones, which regular chunks can't guarantee. + request.completedImportChunks.push( + stringToChunk(outlinedId.toString(16) + ':' + json + '\n'), + ); + const ref = serializeByValueID(outlinedId); + writtenStrings.set(value, ref); + return ref; +} + let modelRoot: null | ReactClientValue = false; function renderModel( @@ -4584,14 +4645,140 @@ function emitErrorChunk( } } +// Null on the debug channel, which can't reference rows in the main stream. +let importStringRequest: null | Request = null; + +function importMetadataReplacer(key: string, value: mixed): mixed { + if (typeof value === 'string') { + const request = importStringRequest; + if (request === null) { + return escapeStringValue(value); + } + return serializeImportString(request, value); + } + return value; +} + +function stringifyImportMetadataWithReplacer( + request: Request, + clientReferenceMetadata: ClientReferenceMetadata, + debug: boolean, +): string { + const prevRequest = importStringRequest; + importStringRequest = __DEV__ && debug ? null : request; + try { + // $FlowFixMe[incompatible-type] stringify can return null + return stringify(clientReferenceMetadata, importMetadataReplacer); + } finally { + importStringRequest = prevRequest; + } +} + +// Bundler metadata is two or three levels deep. The bound is only there so a +// cycle ends up in stringify itself, which throws its own error for it. +const MAX_IMPORT_METADATA_DEPTH = 16; + +const NOT_PLAIN_IMPORT_METADATA = {}; + +// Copies the metadata with every string replaced by its serialized form, so +// that stringify can run without a replacer. Anything stringify would treat +// specially (toJSON, boxed primitives, class instances) makes this give up +// instead, because the copy would not reproduce that treatment. +function transformImportMetadata( + request: Request, + value: mixed, + depth: number, +): mixed { + switch (typeof value) { + case 'string': + return serializeImportString(request, value); + case 'number': + case 'boolean': + case 'undefined': + return value; + case 'object': { + if (value === null) { + return null; + } + if (depth > MAX_IMPORT_METADATA_DEPTH) { + return NOT_PLAIN_IMPORT_METADATA; + } + if (typeof (value as any).toJSON === 'function') { + return NOT_PLAIN_IMPORT_METADATA; + } + if (isArray(value)) { + const length = value.length; + const copy: Array = new Array(length); + for (let i = 0; i < length; i++) { + const element = value[i]; + if (typeof element === 'string') { + copy[i] = serializeImportString(request, element); + continue; + } + const child = transformImportMetadata(request, element, depth + 1); + if (child === NOT_PLAIN_IMPORT_METADATA) { + return NOT_PLAIN_IMPORT_METADATA; + } + copy[i] = child; + } + return copy; + } + const proto = getPrototypeOf(value); + if (proto !== ObjectPrototype && proto !== null) { + return NOT_PLAIN_IMPORT_METADATA; + } + const keys = Object.keys(value); + const copy: {[string]: mixed} = {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key in ObjectPrototype) { + // The copy inherits from Object.prototype, so assigning this key would + // hit an accessor like __proto__ or, if the prototype is frozen, throw. + return NOT_PLAIN_IMPORT_METADATA; + } + const element = (value as any)[key]; + if (typeof element === 'string') { + copy[key] = serializeImportString(request, element); + continue; + } + const child = transformImportMetadata(request, element, depth + 1); + if (child === NOT_PLAIN_IMPORT_METADATA) { + return NOT_PLAIN_IMPORT_METADATA; + } + copy[key] = child; + } + return copy; + } + default: + return NOT_PLAIN_IMPORT_METADATA; + } +} + +function stringifyImportMetadata( + request: Request, + clientReferenceMetadata: ClientReferenceMetadata, + debug: boolean, +): string { + if (!(__DEV__ && debug)) { + const copy = transformImportMetadata(request, clientReferenceMetadata, 0); + if (copy !== NOT_PLAIN_IMPORT_METADATA) { + // $FlowFixMe[incompatible-type] stringify can return null + return stringify(copy); + } + } + return stringifyImportMetadataWithReplacer( + request, + clientReferenceMetadata, + debug, + ); +} + function emitImportChunk( request: Request, id: number, - clientReferenceMetadata: ClientReferenceMetadata, + json: string, debug: boolean, ): void { - // $FlowFixMe[incompatible-type] stringify can return null - const json: string = stringify(clientReferenceMetadata); const row = serializeRowHeader('I', id) + json + '\n'; const processedChunk = stringToChunk(row); if (__DEV__ && debug) { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a1ebcc3106f5..ba39b2b6acc5 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -592,5 +592,6 @@ "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.", "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.", "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue.", - "607": "This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use" + "607": "This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use", + "608": "A client reference was blocked on a row that has not been received yet. This is a bug in React." }