diff --git a/.github/actions/setup-datadog-ci/action.yml b/.github/actions/setup-datadog-ci/action.yml new file mode 100644 index 000000000000..a3c37e3b8bee --- /dev/null +++ b/.github/actions/setup-datadog-ci/action.yml @@ -0,0 +1,37 @@ +name: 'Setup Datadog CI' +description: 'Installs the datadog-ci binary and sets $DATADOG_CI_PATH' +runs: + using: 'composite' + steps: + - name: 'Install datadog-ci' + shell: bash + run: | + echo "DATADOG_CI_PATH=/bin/false" >> $GITHUB_ENV + + DATADOG_CI_VERSION=v5.12.1 + DATADOG_CI_PATH="/tmp/nextjs-ci-bin-datadog-ci-$DATADOG_CI_VERSION" + case "$RUNNER_OS" in + Windows) + DATADOG_CI_PATH="$DATADOG_CI_PATH.exe" + DATADOG_CI_SHA256=4b8320d0b5644c9370e01fd9e38e6f0306c709757c45238416b8eae679c41f75 + DATADOG_CI_ASSET=datadog-ci_win-x64 + ;; + *) + DATADOG_CI_SHA256=86fcf24d5211f5ae714e947354ccb621e74e2bba4162247890454c6461e74ca5 + DATADOG_CI_ASSET=datadog-ci_linux-x64 + ;; + esac + + if [[ ! -x "$DATADOG_CI_PATH" ]]; then + echo "Downloading $DATADOG_CI_PATH" + curl -L --fail --retry 2 -o "$DATADOG_CI_PATH" \ + "https://github.com/DataDog/datadog-ci/releases/download/$DATADOG_CI_VERSION/$DATADOG_CI_ASSET" + if ! echo "$DATADOG_CI_SHA256 $DATADOG_CI_PATH" | sha256sum --check --status; then + echo "Checksum mismatch of $DATADOG_CI_PATH" + rm -f "$DATADOG_CI_PATH" + exit 1 + fi + chmod +x "$DATADOG_CI_PATH" + fi + + echo "DATADOG_CI_PATH=$DATADOG_CI_PATH" >> $GITHUB_ENV diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index f02d14d90e9e..4dc92b594a4c 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -21,8 +21,11 @@ env: NAPI_CLI_VERSION: 2.18.4 TURBO_VERSION: 2.8.11 NODE_LTS_VERSION: 20 - TURBO_TEAM: 'vercel' - TURBO_CACHE: 'remote:rw' + TURBO_TEAM: 'vtest314-next-adapter-e2e-tests' + # Prefer shared remote cache across runs, but keep local cache enabled so jobs + # degrade gracefully if the remote cache or token is unavailable. + TURBO_CACHE: 'local:rw,remote:rw' + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # Without this environment variable, rust-lld will fail because some dependencies defaults to newer version of macOS by default. # # See https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html#os-version for more details @@ -36,6 +39,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork }} outputs: value: ${{ steps.deploy-target.outputs.value }} + release_environment: ${{ steps.deploy-target.outputs.release_environment }} steps: - uses: actions/checkout@v4 with: @@ -56,10 +60,32 @@ jobs: # 'staging' for canary branch since that will eventually be published i.e. become the production build. id: deploy-target run: | - if [[ $(node ./scripts/check-is-release.js 2> /dev/null || :) == v* ]]; + RELEASE_VERSION="$(node ./scripts/check-is-release.js 2> /dev/null || :)" + RELEASE_ENVIRONMENT="" + + if [[ "$RELEASE_VERSION" == v* ]]; then echo "value=production" >> $GITHUB_OUTPUT - elif [ '${{ github.ref }}' == 'refs/heads/canary' ] + if [[ "$RELEASE_VERSION" == *-canary.* ]]; + then + RELEASE_ENVIRONMENT="release-canary" + elif [[ "$RELEASE_VERSION" == *-beta.* ]]; + then + RELEASE_ENVIRONMENT="release-beta" + elif [[ "$RELEASE_VERSION" == *-rc.* ]]; + then + RELEASE_ENVIRONMENT="release-release-candidate" + elif [[ "$RELEASE_VERSION" != *-* ]]; + then + RELEASE_ENVIRONMENT="release-stable" + fi + if [[ -z "$RELEASE_ENVIRONMENT" ]]; + then + echo "::error::Missing release environment for $RELEASE_VERSION" + exit 1 + fi + echo "release_environment=$RELEASE_ENVIRONMENT" >> $GITHUB_OUTPUT + elif [ '${{ github.ref }}' == 'refs/heads/next-16-2' ] then echo "value=staging" >> $GITHUB_OUTPUT elif [ '${{ github.event_name }}' == 'workflow_dispatch' ] @@ -158,64 +184,47 @@ jobs: target: ${{ needs.deploy-target.outputs.value == 'automated-preview' && 'x86_64-apple-darwin' }} settings: - - host: - - 'self-hosted' - - 'macos' - - 'arm64' - + - host: 'macos-15-intel' target: 'x86_64-apple-darwin' # --env-mode loose is a breaking change required with turbo 2.x since Strict mode is now the default # TODO: we should add the relevant envs later to to switch to strict mode build: | npm i -g "@napi-rs/cli@${NAPI_CLI_VERSION}" && corepack enable - pnpm dlx turbo@${TURBO_VERSION} run build-native-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize -- --target x86_64-apple-darwin + TURBO_CACHE_FLAG="${NEXT_SKIP_BUILD_CACHE:+--force}" + pnpm dlx turbo@${TURBO_VERSION} run build-native-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize ${TURBO_CACHE_FLAG} -- --target x86_64-apple-darwin strip -x packages/next-swc/native/next-swc.*.node - - host: - - 'self-hosted' - - 'macos' - - 'arm64' - + - host: 'macos-15' target: 'aarch64-apple-darwin' # --env-mode loose is a breaking change required with turbo 2.x since Strict mode is now the default # TODO: we should add the relevant envs later to to switch to strict mode build: | npm i -g "@napi-rs/cli@${NAPI_CLI_VERSION}" && corepack enable - pnpm dlx turbo@${TURBO_VERSION} run build-native-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize -- --target aarch64-apple-darwin + TURBO_CACHE_FLAG="${NEXT_SKIP_BUILD_CACHE:+--force}" + pnpm dlx turbo@${TURBO_VERSION} run build-native-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize ${TURBO_CACHE_FLAG} -- --target aarch64-apple-darwin strip -x packages/next-swc/native/next-swc.*.node - - host: - - 'self-hosted' - - 'windows' - - 'x64' - + - host: 'windows-latest-8-core-oss' # --env-mode loose is a breaking change required with turbo 2.x since Strict mode is now the default # TODO: we should add the relevant envs later to to switch to strict mode build: | corepack enable npm i -g "@napi-rs/cli@${NAPI_CLI_VERSION}" - pnpm dlx turbo@${TURBO_VERSION} run build-native-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize -- --target x86_64-pc-windows-msvc + TURBO_CACHE_FLAG="${NEXT_SKIP_BUILD_CACHE:+--force}" + pnpm dlx turbo@${TURBO_VERSION} run build-native-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize ${TURBO_CACHE_FLAG} -- --target x86_64-pc-windows-msvc target: 'x86_64-pc-windows-msvc' - - host: - - 'self-hosted' - - 'windows' - - 'x64' - + - host: 'windows-latest-8-core-oss' target: 'aarch64-pc-windows-msvc' # --env-mode loose is a breaking change required with turbo 2.x since Strict mode is now the default # TODO: we should add the relevant envs later to to switch to strict mode build: | corepack enable npm i -g "@napi-rs/cli@${NAPI_CLI_VERSION}" - pnpm dlx turbo@${TURBO_VERSION} run build-native-no-plugin-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize -- --target aarch64-pc-windows-msvc - - - host: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' + TURBO_CACHE_FLAG="${NEXT_SKIP_BUILD_CACHE:+--force}" + pnpm dlx turbo@${TURBO_VERSION} run build-native-no-plugin-release -vvv --env-mode loose --remote-cache-timeout 90 --summarize ${TURBO_CACHE_FLAG} -- --target aarch64-pc-windows-msvc + - host: 'ubuntu-latest-16-core-oss' target: 'x86_64-unknown-linux-gnu' # [NOTE] If you want to update / modify build steps, check these things: # - We use docker images to pin the glibc version to link against, @@ -226,6 +235,13 @@ jobs: # environment. docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:stable-2023-09-17-x64 build: | + # /build is bind-mounted from the host with a uid that differs from the + # container's root user; mark it safe so vergen's `git rev-parse` works. + git config --global --add safe.directory /build + rm /usr/share/keyrings/nodesource.gpg + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg + sed -i 's/jammy/nodistro/g' /etc/apt/sources.list.d/nodesource.list apt update apt install -y pkg-config xz-utils dav1d libdav1d-dev rustup show @@ -237,15 +253,13 @@ jobs: strip native/next-swc.*.node objdump -T native/next-swc.*.node | grep GLIBC_ - - host: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' - + - host: 'ubuntu-latest-16-core-oss' target: 'x86_64-unknown-linux-musl' docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:stable-2023-09-17-alpine build: | + # /build is bind-mounted from the host with a uid that differs from the + # container's root user; mark it safe so vergen's `git rev-parse` works. + git config --global --add safe.directory /build apk update apk del llvm apk add --no-cache libc6-compat pkgconfig dav1d libdav1d dav1d-dev clang20-static llvm20 llvm20-dev @@ -258,15 +272,17 @@ jobs: npm run build-native-release -- --target x86_64-unknown-linux-musl strip native/next-swc.*.node - - host: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' - + - host: 'ubuntu-latest-16-core-oss' target: 'aarch64-unknown-linux-gnu' docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:stable-2023-09-17-aarch64 build: | + # /build is bind-mounted from the host with a uid that differs from the + # container's root user; mark it safe so vergen's `git rev-parse` works. + git config --global --add safe.directory /build + rm /usr/share/keyrings/nodesource.gpg + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg + sed -i 's/jammy/nodistro/g' /etc/apt/sources.list.d/nodesource.list apt update apt install -y pkg-config xz-utils dav1d libdav1d-dev export JEMALLOC_SYS_WITH_LG_PAGE=16 @@ -280,15 +296,13 @@ jobs: llvm-strip -x native/next-swc.*.node objdump -T native/next-swc.*.node | grep GLIBC_ - - host: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' - + - host: 'ubuntu-latest-16-core-oss' target: 'aarch64-unknown-linux-musl' docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:stable-2023-09-17-alpine build: | + # /build is bind-mounted from the host with a uid that differs from the + # container's root user; mark it safe so vergen's `git rev-parse` works. + git config --global --add safe.directory /build apk update apk del llvm apk add --no-cache libc6-compat pkgconfig dav1d libdav1d dav1d-dev clang20-static llvm20 llvm20-dev @@ -302,23 +316,20 @@ jobs: npm run build-native-release -- --target aarch64-unknown-linux-musl llvm-strip -x native/next-swc.*.node - name: stable - ${{ matrix.settings.target }} - node@16 + name: stable - ${{ matrix.settings.target }} - node@20 runs-on: ${{ matrix.settings.host }} - timeout-minutes: 45 + timeout-minutes: ${{ contains(matrix.settings.target, 'apple-darwin') && 90 || 45 }} + env: + # Disable all build caches for production/staging/force-preview deploys + NEXT_SKIP_BUILD_CACHE: ${{ contains(fromJSON('["production","staging","force-preview"]'), needs.deploy-target.outputs.value) && '1' || '' }} steps: # https://github.com/actions/virtual-environments/issues/1187 - name: tune linux network run: sudo ethtool -K eth0 tx off rx off - if: ${{ matrix.settings.host == 'ubuntu-latest' }} - - name: tune linux network - run: sudo ethtool -K eth0 tx off rx off - if: ${{ matrix.settings.host == 'ubuntu-latest' }} - - name: tune windows network - run: Disable-NetAdapterChecksumOffload -Name * -TcpIPv4 -UdpIPv4 -TcpIPv6 -UdpIPv6 - if: ${{ matrix.settings.host == 'windows-latest' }} + if: ${{ matrix.settings.host == 'ubuntu-latest-16-core-oss' }} - name: tune mac network run: sudo sysctl -w net.link.generic.system.hwcksum_tx=0 && sudo sysctl -w net.link.generic.system.hwcksum_rx=0 - if: ${{ matrix.settings.host == 'macos-latest' }} + if: ${{ startsWith(matrix.settings.host, 'macos-15') }} # we use checkout here instead of the build cache since # it can fail to restore in different OS' - uses: actions/checkout@v4 @@ -334,10 +345,12 @@ jobs: node-version: ${{ env.NODE_LTS_VERSION }} check-latest: true + - name: Prepare corepack + if: ${{ matrix.settings.host != 'windows-latest-8-core-oss' }} + run: npm i -g corepack@0.31 + - name: Setup corepack - run: | - npm i -g corepack@0.31 - corepack enable + run: corepack enable # we always want to run this to set environment variables - name: Install Rust @@ -354,6 +367,7 @@ jobs: - name: Cache on ${{ github.ref_name }} uses: ijjk/rust-cache@turbo-cache-v1.0.9 + if: ${{ !env.NEXT_SKIP_BUILD_CACHE }} with: save-if: 'true' cache-provider: 'turbo' @@ -366,24 +380,24 @@ jobs: # as they are on an older Node.js version and have # issues with turbo caching - name: pull build cache - if: ${{ matrix.settings.docker }} + if: ${{ matrix.settings.docker && !env.NEXT_SKIP_BUILD_CACHE }} run: TURBO_VERSION=${TURBO_VERSION} node ./scripts/pull-turbo-cache.js ${{ matrix.settings.target }} - name: check build exists - if: ${{ matrix.settings.docker }} + if: ${{ matrix.settings.docker && !env.NEXT_SKIP_BUILD_CACHE }} run: if [ -f packages/next-swc/native/next-swc.*.node ]; then echo "BUILD_EXISTS=yes" >> $GITHUB_OUTPUT; else echo "BUILD_EXISTS=no" >> $GITHUB_OUTPUT; fi id: build-exists - name: Build in docker - if: ${{ matrix.settings.docker && steps.build-exists.outputs.BUILD_EXISTS == 'no' }} + if: ${{ matrix.settings.docker && (env.NEXT_SKIP_BUILD_CACHE || steps.build-exists.outputs.BUILD_EXISTS == 'no') }} env: # put the command in an environment variable to avoid escaping issues DOCKER_CMD: ${{ matrix.settings.build }} run: | - docker run -v "/var/run/docker.sock":"/var/run/docker.sock" \ + docker run --rm -v "/var/run/docker.sock":"/var/run/docker.sock" \ -e CI -e RUST_BACKTRACE -e NAPI_CLI_VERSION -e CARGO_TERM_COLOR -e CARGO_INCREMENTAL \ - -e CARGO_PROFILE_RELEASE_LTO -e CARGO_REGISTRIES_CRATES_IO_PROTOCOL -e TURBO_API \ - -e TURBO_TEAM -e TURBO_TOKEN -e TURBO_VERSION -e TURBO_CACHE="remote:rw" \ + -e CARGO_PROFILE_RELEASE_LTO -e CARGO_REGISTRIES_CRATES_IO_PROTOCOL \ + ${{ env.NEXT_SKIP_BUILD_CACHE && ' ' || '-e TURBO_API -e TURBO_TEAM -e TURBO_TOKEN -e TURBO_VERSION -e TURBO_CACHE=remote:rw' }} \ -v ${{ env.HOME }}/.cargo/git:/root/.cargo/git \ -v ${{ env.HOME }}/.cargo/registry:/root/.cargo/registry \ -v ${{ github.workspace }}:/build \ @@ -449,11 +463,7 @@ jobs: matrix: target: [web, nodejs] - runs-on: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' + runs-on: ubuntu-latest-16-core-oss steps: - uses: actions/checkout@v4 @@ -560,6 +570,7 @@ jobs: if: ${{ needs.deploy-target.outputs.value == 'production' }} name: Potentially publish release runs-on: ubuntu-latest + environment: ${{ needs.deploy-target.outputs.release_environment }} needs: - deploy-target - build @@ -568,14 +579,13 @@ jobs: permissions: contents: write id-token: write - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} steps: - name: Setup node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_LTS_VERSION }} + node-version: 24 check-latest: true + registry-url: 'https://registry.npmjs.org' - name: Setup corepack run: | npm i -g corepack@0.31 @@ -609,16 +619,24 @@ jobs: merge-multiple: true path: crates/wasm - - run: npm i -g npm@10.4.0 # need latest version for provenance (pinning to avoid bugs) - - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-contents: write + - run: ./scripts/publish-native.js - run: ./scripts/publish-release.js env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} publish-turbopack-npm-packages: # Matches the commit message written by turbopack/xtask/src/publish.rs:377 - if: "${{(github.ref == 'refs/heads/canary') && startsWith(github.event.head_commit.message, 'chore: release turbopack npm packages')}}" + if: "${{(github.ref == 'refs/heads/next-16-2') && startsWith(github.event.head_commit.message, 'chore: release turbopack npm packages')}}" runs-on: ubuntu-latest permissions: contents: write @@ -693,6 +711,11 @@ jobs: env: DATADOG_API_KEY: ${{ secrets.DATA_DOG_API_KEY }} steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + .github + - name: Collect bytesize metrics uses: actions/download-artifact@v4 with: @@ -700,14 +723,18 @@ jobs: merge-multiple: true path: turbopack-bin-size + - name: Install datadog-ci + uses: ./.github/actions/setup-datadog-ci + - name: Upload to Datadog + if: ${{ env.DATADOG_API_KEY != '' }} run: | ls -al turbopack-bin-size for filename in turbopack-bin-size/*; do - export BYTESIZE+=" --metrics $(cat $filename)" + export BYTESIZE+=" --measures $(cat $filename)" done echo "Reporting $BYTESIZE" - npx @datadog/datadog-ci@2.23.1 metric --no-fail --level pipeline $BYTESIZE + "$DATADOG_CI_PATH" measure --no-fail --level pipeline $BYTESIZE diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index d35202b7b315..720fcad1f4cc 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -2,7 +2,7 @@ name: build-and-test on: push: - branches: ['canary'] + branches: ['next-16-2'] pull_request: types: [opened, synchronize] @@ -81,7 +81,7 @@ jobs: with: skipInstallBuild: 'yes' stepName: 'build-native-windows' - runs_on_labels: '["windows","self-hosted","x64"]' + runs_on_labels: '["windows-latest-8-core-oss"]' buildNativeTarget: 'x86_64-pc-windows-msvc' secrets: inherit @@ -711,7 +711,7 @@ jobs: nodeVersion: ${{ matrix.node }} afterBuild: node run-tests.js --type unit stepName: 'test-unit-windows-${{ matrix.node }}' - runs_on_labels: '["windows","self-hosted","x64"]' + runs_on_labels: '["windows-latest-8-core-oss"]' buildNativeTarget: 'x86_64-pc-windows-msvc' secrets: inherit @@ -720,7 +720,7 @@ jobs: name: Test new and changed tests for flakes (dev) needs: ['optimize-ci', 'changes', 'build-native', 'build-next'] # test-new-tests-if - if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} + if: false # test-new-tests-end-if strategy: @@ -745,7 +745,7 @@ jobs: name: Test new and changed tests for flakes (prod) needs: ['optimize-ci', 'changes', 'build-native', 'build-next'] # test-new-tests-if - if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} + if: false # test-new-tests-end-if strategy: @@ -767,10 +767,9 @@ jobs: test-new-tests-deploy: name: Test new and changed tests when deployed - needs: - ['optimize-ci', 'test-prod', 'test-new-tests-dev', 'test-new-tests-start'] + needs: ['optimize-ci', 'test-prod', 'test-new-tests-start'] # test-new-tests-if - if: ${{ needs.optimize-ci.outputs.skip == 'false' }} + if: false # test-new-tests-end-if strategy: @@ -794,15 +793,9 @@ jobs: test-new-tests-deploy-cache-components: name: Test new and changed tests when deployed (cache components) - needs: - [ - 'optimize-ci', - 'test-cache-components-prod', - 'test-new-tests-dev', - 'test-new-tests-start', - ] + needs: ['optimize-ci', 'test-cache-components-prod'] # test-new-tests-if - if: ${{ needs.optimize-ci.outputs.skip == 'false' }} + if: false # test-new-tests-end-if strategy: @@ -891,7 +884,7 @@ jobs: test/e2e/app-dir/proxy-runtime-nodejs/proxy-runtime-nodejs.test.ts \ test/development/app-dir/segment-explorer/segment-explorer.test.ts stepName: 'test-dev-windows' - runs_on_labels: '["windows","self-hosted","x64"]' + runs_on_labels: '["windows-latest-8-core-oss"]' buildNativeTarget: 'x86_64-pc-windows-msvc' secrets: inherit @@ -921,7 +914,7 @@ jobs: test/integration/create-next-app/index.test.ts \ test/integration/create-next-app/package-manager/pnpm.test.ts stepName: 'test-integration-windows' - runs_on_labels: '["windows","self-hosted","x64"]' + runs_on_labels: '["windows-latest-8-core-oss"]' buildNativeTarget: 'x86_64-pc-windows-msvc' secrets: inherit @@ -951,7 +944,7 @@ jobs: test/e2e/app-dir/non-root-project-monorepo/non-root-project-monorepo.test.ts \ test/e2e/app-dir/proxy-runtime-nodejs/proxy-runtime-nodejs.test.ts stepName: 'test-prod-windows' - runs_on_labels: '["windows","self-hosted","x64"]' + runs_on_labels: '["windows-latest-8-core-oss"]' buildNativeTarget: 'x86_64-pc-windows-msvc' secrets: inherit @@ -1054,19 +1047,24 @@ jobs: # these all run without concurrency because they're heavier export TEST_CONCURRENCY=1 - export IS_WEBPACK_TEST=1 - - BROWSER_NAME=firefox node run-tests.js \ + export IS_TURBOPACK_TEST=1 + TURBOPACK_BUILD=1 NEXT_TEST_MODE=start BROWSER_NAME=firefox node run-tests.js \ test/production/pages-dir/production/test/index.test.ts \ test/production/chunk-load-failure/chunk-load-failure.test.ts - NEXT_TEST_MODE=start BROWSER_NAME=safari node run-tests.js \ + TURBOPACK_DEV=1 NEXT_TEST_MODE=dev BROWSER_NAME=firefox node run-tests.js \ + test/e2e/app-dir/scss/hmr-module/hmr-module.test.ts + + TURBOPACK_DEV=1 NEXT_TEST_MODE=dev BROWSER_NAME=safari node run-tests.js \ + test/e2e/app-dir/scss/hmr-module/hmr-module.test.ts + + TURBOPACK_BUILD=1 NEXT_TEST_MODE=start BROWSER_NAME=safari node run-tests.js \ test/production/pages-dir/production/test/index.test.ts \ test/production/chunk-load-failure/chunk-load-failure.test.ts \ test/e2e/basepath/basepath.test.ts \ test/e2e/basepath/error-pages.test.ts - BROWSER_NAME=safari DEVICE_NAME='iPhone XR' node run-tests.js \ + TURBOPACK_BUILD=1 NEXT_TEST_MODE=start BROWSER_NAME=safari DEVICE_NAME='iPhone XR' node run-tests.js \ test/production/prerender-prefetch/index.test.ts stepName: 'test-firefox-safari' secrets: inherit @@ -1187,10 +1185,6 @@ jobs: 'test-next-swc-wasm', 'test-turbopack-dev', 'test-turbopack-integration', - 'test-new-tests-dev', - 'test-new-tests-start', - 'test-new-tests-deploy', - 'test-new-tests-deploy-cache-components', 'test-turbopack-production', 'test-unit-windows', 'test-dev-windows', diff --git a/.github/workflows/build_reusable.yml b/.github/workflows/build_reusable.yml index 30f04fc459a2..145adad61484 100644 --- a/.github/workflows/build_reusable.yml +++ b/.github/workflows/build_reusable.yml @@ -68,7 +68,7 @@ on: description: 'List of runner labels' required: false type: string - default: '["self-hosted", "linux", "x64", "metal"]' + default: '["ubuntu-latest-16-core-oss"]' buildNativeTarget: description: 'Target for build-native step' required: false @@ -101,9 +101,11 @@ env: # disable backtrace for test snapshots RUST_BACKTRACE: 0 - TURBO_TEAM: 'vtest314-next-e2e-tests' - TURBO_CACHE: 'remote:rw' - TURBO_TOKEN: ${{ secrets.TURBO_REMOTE_CACHE_TOKEN }} + TURBO_TEAM: 'vtest314-next-adapter-e2e-tests' + # Prefer shared remote cache across runs, but keep local cache enabled so jobs + # degrade gracefully if the remote cache or token is unavailable. + TURBO_CACHE: 'local:rw,remote:rw' + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} NEXT_TELEMETRY_DISABLED: 1 # allow not skipping install-native postinstall script if we don't have a binary available already @@ -147,7 +149,7 @@ jobs: steps: # enforce consistent line endings for git on windows - name: Configure git to use LF endings - if: ${{ contains(fromJson(inputs.runs_on_labels), 'windows') }} + if: ${{ runner.os == 'Windows' }} run: | git config --global core.autocrlf false git config --global core.eol lf @@ -167,9 +169,10 @@ jobs: - name: Install fnm if: steps.check-fnm.outputs.found != 'true' run: | + export FNM_DIR="${HOME}/.local/share/fnm" curl -fsSL https://fnm.vercel.app/install | bash - export PATH="/home/runner/.local/share/fnm:$PATH" - echo "/home/runner/.local/share/fnm" >> $GITHUB_PATH + export PATH="$FNM_DIR:$PATH" + echo "$FNM_DIR" >> $GITHUB_PATH fnm env --json | jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' | xargs -I {} echo "{}" >> $GITHUB_ENV - name: Normalize input step names into path key @@ -202,7 +205,7 @@ jobs: which node node --version - name: Prepare corepack - if: ${{ contains(fromJson(inputs.runs_on_labels), 'ubuntu-latest') }} + if: ${{ runner.os == 'Linux' }} run: | npm i -g corepack@0.31 - run: corepack enable @@ -352,13 +355,17 @@ jobs: name: webpack bundle analysis stats-${{ steps.var.outputs.input_step_key }} path: packages/next/dist/compiled/next-server/report.*.html - - name: Upload test report to datadog + - name: Install datadog-ci if: ${{ inputs.afterBuild && always() && !github.event.pull_request.head.repo.fork }} + uses: ./.github/actions/setup-datadog-ci + + - name: Upload test report to datadog + if: ${{ inputs.afterBuild && always() && !github.event.pull_request.head.repo.fork && env.DATADOG_API_KEY != '' }} run: | # Add a `test.type` tag to distinguish between turbopack and next.js runs # Add a `nextjs.test_session.name` tag to help identify the job if [ -d ./test/test-junit-report ]; then - pnpm dlx @datadog/datadog-ci@2.45.1 junit upload \ + "$DATADOG_CI_PATH" junit upload \ --service nextjs \ --tags test.type:nextjs \ --tags test_session.name:"${{ inputs.stepName }}" \ @@ -366,7 +373,7 @@ jobs: ./test/test-junit-report fi if [ -d ./test/turbopack-test-junit-report ]; then - pnpm dlx @datadog/datadog-ci@2.45.1 junit upload \ + "$DATADOG_CI_PATH" junit upload \ --service nextjs \ --tags test.type:turbopack \ --tags test_session.name:"${{ inputs.stepName }}" \ diff --git a/.github/workflows/create_release_branch.yml b/.github/workflows/create_release_branch.yml index 5c1abacb54e0..daf727f7c214 100644 --- a/.github/workflows/create_release_branch.yml +++ b/.github/workflows/create_release_branch.yml @@ -11,10 +11,6 @@ on: type: string required: true - secrets: - RELEASE_BOT_GITHUB_TOKEN: - required: true - name: Create Release Branch env: @@ -39,13 +35,33 @@ jobs: node-version: 20 check-latest: true + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-contents: write + permission-environments: write + permission-workflows: write + + - name: Get GitHub App user ID + id: release-app-user + run: | + user_id="$(gh api "/users/${{ steps.release-app-token.outputs.app-slug }}[bot]" --jq .id)" + echo "user-id=$user_id" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} + - name: Clone Next.js repository run: git clone https://github.com/vercel/next.js.git --depth=25 --single-branch --branch ${GITHUB_REF_NAME:-canary} . - name: Check token run: gh auth status env: - GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} # https://github.com/actions/virtual-environments/issues/1187 - name: tune linux network @@ -70,6 +86,10 @@ jobs: - run: pnpm install - - run: node ./scripts/create-release-branch.js --branch-name ${{ github.event.inputs.branchName }} --tag-name ${{ github.event.inputs.tagName }} + - run: node ./scripts/create-release-branch.js --branch-name "${INPUT_BRANCHNAME}" --tag-name "${INPUT_TAGNAME}" env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} + RELEASE_GITHUB_APP_SLUG: ${{ steps.release-app-token.outputs.app-slug }} + RELEASE_GITHUB_APP_USER_ID: ${{ steps.release-app-user.outputs.user-id }} + INPUT_BRANCHNAME: ${{ github.event.inputs.branchName }} + INPUT_TAGNAME: ${{ github.event.inputs.tagName }} diff --git a/.github/workflows/integration_tests_reusable.yml b/.github/workflows/integration_tests_reusable.yml index a7738651ffe7..e31b946cd207 100644 --- a/.github/workflows/integration_tests_reusable.yml +++ b/.github/workflows/integration_tests_reusable.yml @@ -56,7 +56,7 @@ jobs: secrets: inherit generate-matrices: - runs-on: [self-hosted, linux, x64, metal] + runs-on: ubuntu-latest-16-core-oss steps: - id: out run: | @@ -147,7 +147,7 @@ jobs: collect_nextjs_development_integration_stat: needs: [test-e2e, test-integration] name: Next.js integration test development status report - runs-on: [self-hosted, linux, x64, metal] + runs-on: ubuntu-latest-16-core-oss if: always() permissions: pull-requests: write diff --git a/.github/workflows/pull_request_stats.yml b/.github/workflows/pull_request_stats.yml index 09179e9962f5..a10941afe3d2 100644 --- a/.github/workflows/pull_request_stats.yml +++ b/.github/workflows/pull_request_stats.yml @@ -18,8 +18,11 @@ env: NODE_LTS_VERSION: 20 TEST_CONCURRENCY: 6 - TURBO_TEAM: 'vercel' - TURBO_CACHE: 'remote:rw' + TURBO_TEAM: 'vtest314-next-adapter-e2e-tests' + # Prefer shared remote cache across runs, but keep local cache enabled so jobs + # degrade gracefully if the remote cache or token is unavailable. + TURBO_CACHE: 'local:rw,remote:rw' + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} NEXT_TELEMETRY_DISABLED: 1 # we build a dev binary for use in CI so skip downloading # canary next-swc binaries in the monorepo @@ -47,11 +50,7 @@ jobs: fail-fast: false matrix: bundler: [webpack, turbopack] - runs-on: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' + runs-on: ubuntu-latest-16-core-oss steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/release-next-rspack.yml b/.github/workflows/release-next-rspack.yml index 3b47be425f9c..580a7c327d7f 100644 --- a/.github/workflows/release-next-rspack.yml +++ b/.github/workflows/release-next-rspack.yml @@ -61,9 +61,10 @@ jobs: echo " - 📦 This will PUBLISH packages to npm" fi - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '22' + node-version: '24' + registry-url: 'https://registry.npmjs.org' - name: Enable corepack run: corepack enable @@ -113,12 +114,6 @@ jobs: run: ls -R npm working-directory: ${{ steps.napi-info.outputs.binding-directory }} - - name: Create npm token - run: | - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} - - name: Release npm binding packages run: | npm config set access public diff --git a/.github/workflows/setup-nextjs-build.yml b/.github/workflows/setup-nextjs-build.yml index 33efdc2a49ee..e3674670b9dd 100644 --- a/.github/workflows/setup-nextjs-build.yml +++ b/.github/workflows/setup-nextjs-build.yml @@ -16,11 +16,7 @@ on: jobs: build_nextjs: name: Build Next.js for the turbopack integration test - runs-on: - - 'self-hosted' - - 'linux' - - 'x64' - - 'metal' + runs-on: ubuntu-latest-16-core-oss outputs: output1: ${{ steps.build-next-swc-turbopack-patch.outputs.success }} steps: diff --git a/.github/workflows/test-turbopack-rust-bench-test.yml b/.github/workflows/test-turbopack-rust-bench-test.yml index 702aad55f871..83e6b2af1340 100644 --- a/.github/workflows/test-turbopack-rust-bench-test.yml +++ b/.github/workflows/test-turbopack-rust-bench-test.yml @@ -4,7 +4,7 @@ on: inputs: runner: type: string - default: '["self-hosted", "linux", "x64", "metal"]' + default: '["ubuntu-latest-16-core-oss"]' os: type: string default: 'linux' @@ -17,6 +17,8 @@ env: TURBOPACK_BENCH_PROGRESS: '1' NODE_LTS_VERSION: 20 + TURBO_TEAM: 'vtest314-next-adapter-e2e-tests' + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} jobs: test: diff --git a/.github/workflows/test_e2e_deploy_release.yml b/.github/workflows/test_e2e_deploy_release.yml index 6d96e8c66f6a..0ebc7a3513b0 100644 --- a/.github/workflows/test_e2e_deploy_release.yml +++ b/.github/workflows/test_e2e_deploy_release.yml @@ -231,18 +231,26 @@ jobs: runs-on: ubuntu-latest name: Report test results to datadog steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + .github + - name: Download test report artifacts - id: download-test-reports uses: actions/download-artifact@v4 with: pattern: test-reports-* path: test merge-multiple: true + - name: Install datadog-ci + uses: ./.github/actions/setup-datadog-ci + - name: Upload test report to datadog + if: ${{ env.DATADOG_API_KEY != '' }} run: | if [ -d ./test/test-junit-report ]; then - DD_ENV=ci npx @datadog/datadog-ci@2.23.1 junit upload --tags test.type:deploy --service nextjs ./test/test-junit-report + DD_ENV=ci "$DATADOG_CI_PATH" junit upload --tags test.type:deploy --service nextjs ./test/test-junit-report fi create-draft-prs: diff --git a/.github/workflows/test_e2e_project_reset_cron.yml b/.github/workflows/test_e2e_project_reset_cron.yml index 82dc1cc0758e..c6393a4ba97b 100644 --- a/.github/workflows/test_e2e_project_reset_cron.yml +++ b/.github/workflows/test_e2e_project_reset_cron.yml @@ -13,8 +13,11 @@ env: VERCEL_ADAPTER_TEST_TEAM: vtest314-next-adapter-e2e-tests VERCEL_ADAPTER_TEST_TOKEN: ${{ secrets.VERCEL_ADAPTER_TEST_TOKEN }} NODE_LTS_VERSION: 20 - TURBO_TEAM: 'vercel' - TURBO_CACHE: 'remote:rw' + TURBO_TEAM: 'vtest314-next-adapter-e2e-tests' + # Prefer shared remote cache across runs, but keep local cache enabled so jobs + # degrade gracefully if the remote cache or token is unavailable. + TURBO_CACHE: 'local:rw,remote:rw' + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} run-name: test-e2e-project-reset (scheduled) diff --git a/.github/workflows/trigger_release.yml b/.github/workflows/trigger_release.yml index 0ec4a53a3e1a..ffeb6fbcc848 100644 --- a/.github/workflows/trigger_release.yml +++ b/.github/workflows/trigger_release.yml @@ -45,7 +45,6 @@ jobs: # canary next-swc binaries in the monorepo NEXT_SKIP_NATIVE_POSTINSTALL: 1 - environment: release-${{ github.event.inputs.releaseType || 'canary' }} steps: - name: Setup node uses: actions/setup-node@v4 @@ -53,6 +52,24 @@ jobs: node-version: 20 check-latest: true + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-contents: write + + - name: Get GitHub App user ID + id: release-app-user + run: | + user_id="$(gh api "/users/${{ steps.release-app-token.outputs.app-slug }}[bot]" --jq .id)" + echo "user-id=$user_id" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} + - name: Clone Next.js repository run: git clone https://github.com/vercel/next.js.git --depth=25 --single-branch --branch ${GITHUB_REF_NAME:-canary} . @@ -62,10 +79,13 @@ jobs: # Ignoring failures for now to check if a failure truly implies a failed publish. continue-on-error: true env: - GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} - name: Get commit of the latest tag - run: echo "LATEST_TAG_COMMIT=$(git rev-list -n 1 $(git describe --tags --abbrev=0))" >> $GITHUB_ENV + run: | + git fetch --tags --force --deepen=1000 origin + latest_tag="$(git describe --tags --abbrev=0)" + echo "LATEST_TAG_COMMIT=$(git rev-list -n 1 "$latest_tag")" >> $GITHUB_ENV - name: Get latest commit run: echo "LATEST_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV @@ -101,6 +121,10 @@ jobs: - run: pnpm install - - run: node ./scripts/start-release.js --release-type ${{ github.event.inputs.releaseType || 'canary' }} --semver-type ${{ github.event.inputs.semverType }} + - run: node ./scripts/start-release.js --release-type "${INPUT_RELEASETYPE}" --semver-type "${INPUT_SEMVERTYPE}" env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} + RELEASE_GITHUB_APP_SLUG: ${{ steps.release-app-token.outputs.app-slug }} + RELEASE_GITHUB_APP_USER_ID: ${{ steps.release-app-user.outputs.user-id }} + INPUT_RELEASETYPE: ${{ github.event.inputs.releaseType || 'canary' }} + INPUT_SEMVERTYPE: ${{ github.event.inputs.semverType }} diff --git a/.github/workflows/turbopack-benchmark.yml b/.github/workflows/turbopack-benchmark.yml index 8d7f558ed1be..099b5729bc02 100644 --- a/.github/workflows/turbopack-benchmark.yml +++ b/.github/workflows/turbopack-benchmark.yml @@ -22,13 +22,16 @@ env: CARGO_INCREMENTAL: 0 # For faster CI RUST_LOG: 'off' - TURBO_TEAM: 'vercel' - TURBO_CACHE: 'remote:rw' + TURBO_TEAM: 'vtest314-next-adapter-e2e-tests' + # Prefer shared remote cache across runs, but keep local cache enabled so jobs + # degrade gracefully if the remote cache or token is unavailable. + TURBO_CACHE: 'local:rw,remote:rw' + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} jobs: benchmark-small-apps: name: Benchmark Rust Crates (small apps) - runs-on: ['self-hosted', 'linux', 'x64', 'metal'] + runs-on: ubuntu-latest-16-core-oss steps: - uses: actions/checkout@v4 @@ -68,7 +71,7 @@ jobs: benchmark-analyzer: name: Benchmark Rust Crates (analyzer) - runs-on: ['self-hosted', 'linux', 'x64', 'metal'] + runs-on: ubuntu-latest-16-core-oss steps: - uses: actions/checkout@v4 @@ -109,7 +112,7 @@ jobs: benchmark-large: name: Benchmark Rust Crates (large) if: ${{ github.event.label.name == 'benchmark' || github.event_name == 'workflow_dispatch' }} - runs-on: ['self-hosted', 'linux', 'x64', 'metal'] + runs-on: ubuntu-latest-16-core-oss steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index f36e92df76e1..a9a1c4251b15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6090,9 +6090,9 @@ checksum = "e3a8614ee435691de62bcffcf4a66d91b3594bf1428a5722e79103249a095690" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 6c88f44ca9cc..8dc34de23865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -460,7 +460,7 @@ rand = "0.10.0" rayon = "1.10.0" regex = "1.10.6" regress = "0.10.4" -reqwest = { version = "0.13.1", default-features = false } +reqwest = { version = "0.13.2", default-features = false } ringmap = "0.2.3" roaring = "0.10.10" rstest = "0.16.0" diff --git a/crates/next-api/build.rs b/crates/next-api/build.rs index 65d1aed8ba3b..1ea5e13aa508 100644 --- a/crates/next-api/build.rs +++ b/crates/next-api/build.rs @@ -6,8 +6,8 @@ fn main() -> anyhow::Result<()> { .target_triple(true) .build()?; vergen::Emitter::default() - .add_instructions(&cargo)? .fail_on_error() + .add_instructions(&cargo)? .emit()?; Ok(()) diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index c539f4e37aba..b2f918fbda73 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -874,8 +874,7 @@ impl AppProject { &self, endpoint: Vc, rsc_entry: ResolvedVc>, - client_shared_entries: Vc, - has_layout_segments: bool, + client_shared_entries_when_has_layout_segments: Option>, ) -> Result> { if *self.project.per_page_module_graph().await? { let next_mode = self.project.next_mode(); @@ -883,43 +882,47 @@ impl AppProject { let should_trace = next_mode_ref.is_production(); let should_read_binding_usage = next_mode_ref.is_production(); - let client_shared_entries = client_shared_entries - .await? - .into_iter() - .map(|m| ResolvedVc::upcast(*m)) - .collect(); // Implements layout segment optimization to compute a graph "chain" for each layout // segment async move { let rsc_entry_chunk_group = ChunkGroupEntry::Entry(vec![rsc_entry]); let mut graphs = vec![]; - let mut visited_modules = if has_layout_segments { + let mut visited_modules = VisitedModules::empty(); + + if let Some(client_shared_entries) = client_shared_entries_when_has_layout_segments + { let ServerEntries { - server_utils, server_component_entries, + server_utils, } = &*find_server_entries(*rsc_entry, should_trace, should_read_binding_usage) .await?; + let client_shared_entries = client_shared_entries + .await? + .into_iter() + .map(|m| ResolvedVc::upcast(*m)) + .collect(); + + // SEGMENT: client_shared_entries and server utils shared by the layout segments + // and the page let graph = SingleModuleGraph::new_with_entries_visited_intern( vec![ - ChunkGroupEntry::SharedMerged { - parent: Box::new(rsc_entry_chunk_group.clone()), - merge_tag: NEXT_SERVER_UTILITY_MERGE_TAG.clone(), - entries: server_utils + ChunkGroupEntry::Entry(client_shared_entries), + ChunkGroupEntry::SharedMultiple( + server_utils .iter() .map(async |m| Ok(ResolvedVc::upcast(m.await?.module))) .try_join() .await?, - }, - ChunkGroupEntry::Entry(client_shared_entries), + ), ], - VisitedModules::empty(), + visited_modules, should_trace, should_read_binding_usage, ); graphs.push(graph); - let mut visited_modules = VisitedModules::from_graph(graph); + visited_modules = VisitedModules::concatenate(visited_modules, graph); // Skip the last server component, which is the page itself, because that one // won't have it's visited modules added, and will be visited in the next step @@ -928,6 +931,7 @@ impl AppProject { .iter() .take(server_component_entries.len().saturating_sub(1)) { + // SEGMENT: layout segment let graph = SingleModuleGraph::new_with_entries_visited_intern( vec![ChunkGroupEntry::Shared(ResolvedVc::upcast(*module))], visited_modules, @@ -948,18 +952,9 @@ impl AppProject { VisitedModules::with_incremented_index(visited_modules) }; } - visited_modules - } else { - let graph = SingleModuleGraph::new_with_entries_visited_intern( - vec![ChunkGroupEntry::Entry(client_shared_entries)], - VisitedModules::empty(), - should_trace, - should_read_binding_usage, - ); - graphs.push(graph); - VisitedModules::from_graph(graph) - }; + } + // SEGMENT: rsc entry chunk group let graph = SingleModuleGraph::new_with_entries_visited_intern( vec![rsc_entry_chunk_group], visited_modules, @@ -1254,12 +1249,7 @@ impl AppEndpoint { self, *rsc_entry, // We only need the client runtime entries for pages not for Route Handlers - if is_app_page { - this.app_project.client_runtime_entries() - } else { - EvaluatableAssets::empty() - }, - is_app_page, + is_app_page.then(|| this.app_project.client_runtime_entries()), ) .await?; @@ -2133,13 +2123,14 @@ impl Endpoint for AppEndpoint { async fn module_graphs(self: Vc) -> Result> { let this = self.await?; let app_entry = self.app_endpoint_entry().await?; + let is_app_page = matches!(this.ty, AppEndpointType::Page { .. }); let module_graphs = this .app_project .app_module_graphs( self, *app_entry.rsc_entry, - this.app_project.client_runtime_entries(), - matches!(this.ty, AppEndpointType::Page { .. }), + // We only need the client runtime entries for pages not for Route Handlers + is_app_page.then(|| this.app_project.client_runtime_entries()), ) .await?; Ok(Vc::cell(vec![module_graphs.full])) diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs index 862c5a4b4a3f..49c9ed856f9a 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -184,14 +184,20 @@ impl MiddlewareEndpoint { source.insert_str(0, "/:nextInternalLocale((?!_next/)[^/.]{1,})"); } + // Match transport-specific route forms that resolve to the + // same page: + // - Pages Router data routes: /_next/data//... + // - App Router transport routes: .rsc, ...segments/...segment.rsc if is_root { source.push('('); if has_i18n { - source.push_str("|\\\\.json|"); + source.push_str("|\\.json|"); } - source.push_str("/?index|/?index\\\\.json)?") + source.push_str("/?index|/?index\\.json|"); + source.push_str("/?index(?:\\.rsc|\\.segments/.+\\.segment\\.rsc)"); + source.push_str(")?"); } else { - source.push_str("{(\\\\.json)}?") + source.push_str("{(\\.json|\\.rsc|\\.segments/.+\\.segment\\.rsc)}?"); }; source.insert_str(0, "/:nextData(_next/data/[^/]{1,})?"); diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index b01590d33ff7..243b7aad93c0 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -586,6 +586,13 @@ impl ProjectContainer { "initialize project", project_name = %this.name, version = options.next_version.as_str(), + node_version = options.current_node_js_version.as_str(), + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + cpu_cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0), + dev = options.dev, env_diff = Empty ); let span_clone = span.clone(); @@ -869,7 +876,7 @@ impl ProjectContainer { self.project().entrypoints() } - /// See [Project::hmr_chunk_names]. + /// See [`Project::hmr_chunk_names`]. #[turbo_tasks::function] pub fn hmr_chunk_names(self: Vc, target: HmrTarget) -> Vc> { self.project().hmr_chunk_names(target) @@ -2318,8 +2325,9 @@ impl Project { } /// Gets a list of all HMR chunk names that can be subscribed to for the - /// specified target. This is only needed for testing purposes and isn't - /// used in real apps. + /// specified target. Used by the dev server to set up server-side HMR + /// subscriptions for all Node.js App Router entries (pages and route + /// handlers). #[turbo_tasks::function] pub async fn hmr_chunk_names(self: Vc, target: HmrTarget) -> Result>> { if let Some(map) = self.await?.versioned_content_map { diff --git a/crates/next-core/src/app_page_loader_tree.rs b/crates/next-core/src/app_page_loader_tree.rs index 77292e4ac9bd..46b8c5511d17 100644 --- a/crates/next-core/src/app_page_loader_tree.rs +++ b/crates/next-core/src/app_page_loader_tree.rs @@ -189,7 +189,7 @@ impl AppPageLoaderTreeBuilder { // when mixing ESM imports and requires). self.base.imports.push( format!( - "const {identifier} = require(/*turbopackChunkingType: \ + "const {identifier} = () => require(/*turbopackChunkingType: \ shared*/\"{inner_module_id}\");" ) .into(), @@ -208,7 +208,10 @@ impl AppPageLoaderTreeBuilder { .insert(inner_module_id.into(), module); let s = " "; - writeln!(self.loader_tree_code, "{s}{identifier}.default,")?; + writeln!( + self.loader_tree_code, + "{s}async (props) => interopDefault(await {identifier}())(props)," + )?; } } Ok(()) @@ -227,8 +230,7 @@ impl AppPageLoaderTreeBuilder { let identifier = magic_identifier::mangle(&format!("{name} #{i}")); let inner_module_id = format!("METADATA_{i}"); let helper_import = rcstr!( - "import { fillMetadataSegment } from 'next/dist/lib/metadata/get-metadata-route' with \ - { 'turbopack-transition': 'next-server-utility' }" + "import { fillMetadataSegment } from 'next/dist/lib/metadata/get-metadata-route'" ); if !self.base.imports.contains(&helper_import) { @@ -240,7 +242,7 @@ impl AppPageLoaderTreeBuilder { // requires). self.base.imports.push( format!( - "const {identifier} = require(/*turbopackChunkingType: \ + "const {identifier} = () => require(/*turbopackChunkingType: \ shared*/\"{inner_module_id}\");" ) .into(), @@ -255,8 +257,51 @@ impl AppPageLoaderTreeBuilder { .inner_assets .insert(inner_module_id.into(), module); + let alt = if let Some(alt_path) = alt_path { + let identifier = magic_identifier::mangle(&format!("{name} alt text #{i}")); + let inner_module_id = format!("METADATA_ALT_{i}"); + + // This should use the same importing mechanism as create_module_tuple_code, so that the + // relative order of items is retained (which isn't the case when mixing ESM imports and + // requires). + self.base.imports.push( + format!( + "const {identifier} = () => require(/*turbopackChunkingType: \ + shared*/\"{inner_module_id}\");" + ) + .into(), + ); + + let module = self + .base + .process_source(Vc::upcast(TextContentFileSource::new(Vc::upcast( + FileSource::new(alt_path), + )))) + .to_resolved() + .await?; + + self.base + .inner_assets + .insert(inner_module_id.into(), module); + + Some(identifier) + } else { + None + }; + let s = " "; - writeln!(self.loader_tree_code, "{s}(async (props) => [{{")?; + writeln!(self.loader_tree_code, "{s}(async (props) => {{")?; + writeln!( + self.loader_tree_code, + "{s} const mod = interopDefault(await {identifier}());" + )?; + if let Some(alt) = &alt { + writeln!( + self.loader_tree_code, + "{s} const alt = interopDefault(await {alt}());" + )?; + } + writeln!(self.loader_tree_code, "{s} return [{{")?; let pathname_prefix = if let Some(base_path) = &self.base_path { format!("{base_path}/{app_page}") } else { @@ -265,68 +310,37 @@ impl AppPageLoaderTreeBuilder { let metadata_route = &*get_metadata_route_name(item.clone().into()).await?; writeln!( self.loader_tree_code, - "{s} url: fillMetadataSegment({}, await props.params, {}, true) + \ - `?${{{identifier}.default.src.split(\"/\").splice(-1)[0]}}`,", + "{s} url: fillMetadataSegment({}, await props.params, {}, true) + \ + `?${{mod.src.split(\"/\").splice(-1)[0]}}`,", StringifyJs(&pathname_prefix), StringifyJs(metadata_route), )?; let numeric_sizes = name == "twitter" || name == "openGraph"; if numeric_sizes { - writeln!( - self.loader_tree_code, - "{s} width: {identifier}.default.width," - )?; - writeln!( - self.loader_tree_code, - "{s} height: {identifier}.default.height," - )?; + writeln!(self.loader_tree_code, "{s} width: mod.width,")?; + writeln!(self.loader_tree_code, "{s} height: mod.height,")?; } else { // For SVGs, skip sizes and use "any" to let it scale automatically based on viewport, // For the images doesn't provide the size properly, use "any" as well. // If the size is presented, use the actual size for the image. let sizes = if path.has_extension(".svg") { - "any".to_string() + "any" } else { - format!("${{{identifier}.default.width}}x${{{identifier}.default.height}}") + "${mod.width}x${mod.height}" }; - writeln!(self.loader_tree_code, "{s} sizes: `{sizes}`,")?; + writeln!(self.loader_tree_code, "{s} sizes: `{sizes}`,")?; } let content_type = get_content_type(path).await?; - writeln!(self.loader_tree_code, "{s} type: `{content_type}`,")?; - - if let Some(alt_path) = alt_path { - let identifier = magic_identifier::mangle(&format!("{name} alt text #{i}")); - let inner_module_id = format!("METADATA_ALT_{i}"); - - // This should use the same importing mechanism as create_module_tuple_code, so that the - // relative order of items is retained (which isn't the case when mixing ESM imports and - // requires). - self.base.imports.push( - format!( - "const {identifier} = require(/*turbopackChunkingType: \ - shared*/\"{inner_module_id}\");" - ) - .into(), - ); - - let module = self - .base - .process_source(Vc::upcast(TextContentFileSource::new(Vc::upcast( - FileSource::new(alt_path), - )))) - .to_resolved() - .await?; - - self.base - .inner_assets - .insert(inner_module_id.into(), module); + writeln!(self.loader_tree_code, "{s} type: `{content_type}`,")?; - writeln!(self.loader_tree_code, "{s} alt: {identifier}.default,")?; + if alt.is_some() { + writeln!(self.loader_tree_code, "{s} alt,")?; } - writeln!(self.loader_tree_code, "{s}}}]),")?; + writeln!(self.loader_tree_code, "{s} }}];")?; + writeln!(self.loader_tree_code, "{s}}}),")?; Ok(()) } diff --git a/crates/next-core/src/emit.rs b/crates/next-core/src/emit.rs index dcae1d1d4c72..525d586b0536 100644 --- a/crates/next-core/src/emit.rs +++ b/crates/next-core/src/emit.rs @@ -1,9 +1,16 @@ -use anyhow::Result; +use anyhow::{Ok, Result}; +use futures::join; +use smallvec::{SmallVec, smallvec}; use tracing::Instrument; -use turbo_tasks::{TryFlatJoinIterExt, Vc}; -use turbo_tasks_fs::{FileSystemPath, rebase}; +use turbo_rcstr::RcStr; +use turbo_tasks::{ + FxIndexMap, ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt, ValueToStringRef, Vc, +}; +use turbo_tasks_fs::{FileContent, FileSystemPath, rebase}; +use turbo_tasks_hash::{encode_hex, hash_xxh3_hash64}; use turbopack_core::{ - asset::Asset, + asset::{Asset, AssetContent}, + issue::{Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString}, output::{ExpandedOutputAssets, OutputAsset, OutputAssets}, reference::all_assets_from_entries, }; @@ -43,40 +50,124 @@ pub async fn emit_assets( client_relative_path: FileSystemPath, client_output_path: FileSystemPath, ) -> Result<()> { - let _: Vec<()> = assets + enum Location { + Node, + Client, + } + let assets = assets .await? .iter() .copied() - .map(|asset| { - let node_root = node_root.clone(); - let client_relative_path = client_relative_path.clone(); - let client_output_path = client_output_path.clone(); - - async move { - let path = asset.path().owned().await?; - let span = tracing::info_span!("emit asset", name = %path.value_to_string().await?); - async move { - Ok(if path.is_inside_ref(&node_root) { - Some(emit(*asset).as_side_effect().await?) - } else if path.is_inside_ref(&client_relative_path) { - // Client assets are emitted to the client output path, which is prefixed - // with _next. We need to rebase them to remove that - // prefix. - Some( - emit_rebase(*asset, client_relative_path, client_output_path) - .as_side_effect() - .await?, - ) - } else { - None - }) - } - .instrument(span) - .await - } + .map(async |asset| { + let path = asset.path().owned().await?; + let location = if path.is_inside_ref(&node_root) { + Location::Node + } else if path.is_inside_ref(&client_relative_path) { + Location::Client + } else { + return Ok(None); + }; + Ok(Some((location, path, asset))) }) .try_flat_join() .await?; + + type AssetVec = SmallVec<[ResolvedVc>; 1]>; + let mut node_assets_by_path: FxIndexMap = FxIndexMap::default(); + let mut client_assets_by_path: FxIndexMap = FxIndexMap::default(); + for (location, path, asset) in assets { + match location { + Location::Node => { + node_assets_by_path + .entry(path) + .or_insert_with(|| smallvec![]) + .push(asset); + } + Location::Client => { + client_assets_by_path + .entry(path) + .or_insert_with(|| smallvec![]) + .push(asset); + } + } + } + + /// Checks for duplicate assets at the same path. If duplicates with + /// different content are found, emits an `EmitConflictIssue` for each + /// conflict but still returns the first asset so emission can continue. + async fn check_duplicates( + path: &FileSystemPath, + assets: AssetVec, + node_root: &FileSystemPath, + ) -> Result>> { + let mut iter = assets.into_iter(); + let first = iter.next().unwrap(); + for next in iter { + let ext: RcStr = path.extension().into(); + if let Some(detail) = assets_diff(*next, *first, ext, node_root.clone()) + .owned() + .await? + { + EmitConflictIssue { + asset_path: path.clone(), + detail, + } + .resolved_cell() + .emit(); + } + } + Ok(first) + } + + // Use join! instead of try_join! to collect all errors deterministically + // rather than returning whichever branch fails first non-deterministically. + let (node_result, client_result) = join!( + node_assets_by_path + .into_iter() + .map(|(path, assets)| { + let node_root = node_root.clone(); + + async move { + let asset = check_duplicates(&path, assets, &node_root).await?; + let span = tracing::info_span!( + "emit asset", + name = %path.to_string_ref().await? + ); + async move { emit(*asset).as_side_effect().await } + .instrument(span) + .await + } + }) + .try_join(), + client_assets_by_path + .into_iter() + .map(|(path, assets)| { + let node_root = node_root.clone(); + let client_relative_path = client_relative_path.clone(); + let client_output_path = client_output_path.clone(); + + async move { + let asset = check_duplicates(&path, assets, &node_root).await?; + let span = tracing::info_span!( + "emit asset", + name = %path.to_string_ref().await? + ); + async move { + // Client assets are emitted to the client output path, which is + // prefixed with _next. We need to rebase them to + // remove that prefix. + emit_rebase(*asset, client_relative_path, client_output_path) + .as_side_effect() + .await + } + .instrument(span) + .await + } + }) + .try_join(), + ); + node_result?; + client_result?; Ok(()) } @@ -110,3 +201,133 @@ async fn emit_rebase( .await?; Ok(()) } + +/// Compares two assets that target the same output path. If their content +/// differs, writes both versions under `node_root` as `.` and +/// returns a description of the difference. +#[turbo_tasks::function] +async fn assets_diff( + asset1: Vc>, + asset2: Vc>, + extension: RcStr, + node_root: FileSystemPath, +) -> Result>> { + let content1 = asset1.content().await?; + let content2 = asset2.content().await?; + + let detail = match (&*content1, &*content2) { + (AssetContent::File(content1), AssetContent::File(content2)) => { + let content1 = content1.await?; + let content2 = content2.await?; + + match (&*content1, &*content2) { + (FileContent::NotFound, FileContent::NotFound) => None, + (FileContent::Content(file1), FileContent::Content(file2)) => { + if file1 == file2 { + None + } else { + // Write both versions under node_root as . so the + // user can diff them. + let ext = &*extension; + let hash1 = encode_hex(hash_xxh3_hash64(file1.content().content_hash())); + let hash2 = encode_hex(hash_xxh3_hash64(file2.content().content_hash())); + let name1 = if ext.is_empty() { + hash1 + } else { + format!("{hash1}.{ext}") + }; + let name2 = if ext.is_empty() { + hash2 + } else { + format!("{hash2}.{ext}") + }; + let path1 = node_root.join(&name1)?; + let path2 = node_root.join(&name2)?; + path1 + .write(FileContent::Content(file1.clone()).cell()) + .as_side_effect() + .await?; + path2 + .write(FileContent::Content(file2.clone()).cell()) + .as_side_effect() + .await?; + Some(format!( + "file content differs, written to:\n {}\n {}", + path1.to_string_ref().await?, + path2.to_string_ref().await?, + )) + } + } + _ => Some( + "assets at the same path have mismatched file content types (one task wants \ + to write the file, another wants to delete it)" + .into(), + ), + } + } + ( + AssetContent::Redirect { + target: target1, + link_type: link_type1, + }, + AssetContent::Redirect { + target: target2, + link_type: link_type2, + }, + ) => { + if target1 == target2 && link_type1 == link_type2 { + None + } else { + Some(format!( + "assets at the same path are both redirects but point to different targets: \ + {target1} vs {target2}" + )) + } + } + _ => Some( + "assets at the same path have different content types (one is a file, the other is a \ + redirect)" + .into(), + ), + }; + + Ok(Vc::cell(detail.map(|d| d.into()))) +} + +#[turbo_tasks::value] +struct EmitConflictIssue { + asset_path: FileSystemPath, + detail: RcStr, +} + +#[turbo_tasks::value_impl] +impl Issue for EmitConflictIssue { + #[turbo_tasks::function] + fn file_path(&self) -> Vc { + self.asset_path.clone().cell() + } + + #[turbo_tasks::function] + fn stage(&self) -> Vc { + IssueStage::Emit.cell() + } + + fn severity(&self) -> IssueSeverity { + IssueSeverity::Error + } + + #[turbo_tasks::function] + fn title(&self) -> Vc { + StyledString::Text( + "Two or more assets with different content were emitted to the same output path".into(), + ) + .cell() + } + + #[turbo_tasks::function] + fn description(&self) -> Vc { + Vc::cell(Some( + StyledString::Text(self.detail.clone()).resolved_cell(), + )) + } +} diff --git a/crates/next-napi-bindings/build.rs b/crates/next-napi-bindings/build.rs index 475cb62e3be0..212c67b304a0 100644 --- a/crates/next-napi-bindings/build.rs +++ b/crates/next-napi-bindings/build.rs @@ -4,7 +4,9 @@ use serde_json::Value; fn main() -> anyhow::Result<()> { println!("cargo:rerun-if-env-changed=CI"); + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS"); let is_ci = env::var("CI").is_ok_and(|value| !value.is_empty()); + let is_macos_target = env::var("CARGO_CFG_TARGET_OS").is_ok_and(|value| value == "macos"); let nextjs_version = { let package_json_path = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -55,9 +57,9 @@ fn main() -> anyhow::Result<()> { ) .build()?; vergen_gitcl::Emitter::default() + .fail_on_error() .add_instructions(&cargo)? .add_instructions(&git)? - .fail_on_error() .emit()?; match Command::new("git").args(["rev-parse", "HEAD"]).output() { @@ -65,15 +67,20 @@ fn main() -> anyhow::Result<()> { "cargo:warning=git HEAD: {}", str::from_utf8(&out.stdout).unwrap() ), - _ => println!("cargo:warning=`git rev-parse HEAD` failed"), + Ok(out) => println!( + "cargo:warning=`git rev-parse HEAD` failed with status {}: {}", + out.status, + str::from_utf8(&out.stderr).unwrap() + ), + Err(e) => println!("cargo:warning=`git rev-parse HEAD` could not be spawned: {e}"), } - #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] - napi_build::setup(); + if !is_macos_target { + napi_build::setup(); + } - // This is a workaround for napi always including a GCC specific flag. - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - { + // This is a workaround for napi always including a GCC-specific flag on macOS. + if is_macos_target { println!("cargo:rerun-if-env-changed=DEBUG_GENERATED_CODE"); println!("cargo:rerun-if-env-changed=TYPE_DEF_TMP_PATH"); println!("cargo:rerun-if-env-changed=CARGO_CFG_NAPI_RS_CLI_VERSION"); diff --git a/docs/01-app/01-getting-started/03-layouts-and-pages.mdx b/docs/01-app/01-getting-started/03-layouts-and-pages.mdx index 95a7f76c9451..f8472b31e294 100644 --- a/docs/01-app/01-getting-started/03-layouts-and-pages.mdx +++ b/docs/01-app/01-getting-started/03-layouts-and-pages.mdx @@ -129,7 +129,7 @@ export default async function Page() { } ``` -```jsx filename="app/blog/[slug]/page.js" switcher +```jsx filename="app/blog/page.js" switcher // Dummy imports import { getPosts } from '@/lib/posts' import { Post } from '@/ui/post' diff --git a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx index 326fa99251f5..0e0bf63d8ea9 100644 --- a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx +++ b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx @@ -218,6 +218,7 @@ export async function generateStaticParams() { return posts.map((post) => ({ slug: post.slug, })) +} export default async function Page({ params }) { const { slug } = await params diff --git a/docs/01-app/01-getting-started/09-revalidating.mdx b/docs/01-app/01-getting-started/09-revalidating.mdx index c130732e294b..f636c65f32c5 100644 --- a/docs/01-app/01-getting-started/09-revalidating.mdx +++ b/docs/01-app/01-getting-started/09-revalidating.mdx @@ -119,7 +119,7 @@ See the [`revalidateTag` API reference](/docs/app/api-reference/functions/revali `updateTag` immediately expires cached data for read-your-own-writes scenarios — the user sees their change right away instead of stale content. Unlike `revalidateTag`, it can only be used in [Server Actions](/docs/app/getting-started/mutating-data). -```tsx filename="app/lib/actions.ts" highlight={1,6} switcher +```tsx filename="app/lib/actions.ts" highlight={1,12} switcher import { updateTag } from 'next/cache' import { redirect } from 'next/navigation' @@ -136,7 +136,7 @@ export async function createPost(formData: FormData) { } ``` -```jsx filename="app/lib/actions.js" highlight={1,6} switcher +```jsx filename="app/lib/actions.js" highlight={1,12} switcher import { updateTag } from 'next/cache' import { redirect } from 'next/navigation' diff --git a/docs/01-app/01-getting-started/10-error-handling.mdx b/docs/01-app/01-getting-started/10-error-handling.mdx index 1550b49f722f..7878dc24e89d 100644 --- a/docs/01-app/01-getting-started/10-error-handling.mdx +++ b/docs/01-app/01-getting-started/10-error-handling.mdx @@ -151,9 +151,14 @@ export default async function Page() { You can call the [`notFound`](/docs/app/api-reference/functions/not-found) function within a route segment and use the [`not-found.js`](/docs/app/api-reference/file-conventions/not-found) file to show a 404 UI. ```tsx filename="app/blog/[slug]/page.tsx" switcher +import { notFound } from 'next/navigation' import { getPostBySlug } from '@/lib/posts' -export default async function Page({ params }: { params: { slug: string } }) { +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { const { slug } = await params const post = getPostBySlug(slug) @@ -166,6 +171,7 @@ export default async function Page({ params }: { params: { slug: string } }) { ``` ```jsx filename="app/blog/[slug]/page.js" switcher +import { notFound } from 'next/navigation' import { getPostBySlug } from '@/lib/posts' export default async function Page({ params }) { @@ -281,13 +287,13 @@ import { unstable_catchError as catchError, type ErrorInfo } from 'next/error' function ErrorFallback( props: { title: string }, - { error, unstable_retry: retry }: ErrorInfo + { error, unstable_retry }: ErrorInfo ) { return (

{props.title}

{error.message}

- +
) } @@ -300,12 +306,12 @@ export default catchError(ErrorFallback) import { unstable_catchError as catchError } from 'next/error' -function ErrorFallback(props, { error, unstable_retry: retry }) { +function ErrorFallback(props, { error, unstable_retry }) { return (

{props.title}

{error.message}

- +
) } diff --git a/docs/01-app/01-getting-started/11-css.mdx b/docs/01-app/01-getting-started/11-css.mdx index 2e33430e9301..a949e2b94864 100644 --- a/docs/01-app/01-getting-started/11-css.mdx +++ b/docs/01-app/01-getting-started/11-css.mdx @@ -210,7 +210,7 @@ export default function Page() { ```jsx filename="app/blog/page.js" switcher import styles from './blog.module.css' -export default function Layout() { +export default function Page() { return
} ``` diff --git a/docs/01-app/01-getting-started/12-images.mdx b/docs/01-app/01-getting-started/12-images.mdx index 0059d00e8e0f..764c22241ac8 100644 --- a/docs/01-app/01-getting-started/12-images.mdx +++ b/docs/01-app/01-getting-started/12-images.mdx @@ -117,6 +117,50 @@ export default function Page() { } ``` +### Images without static imports + +If you can't use a static `import` for your images, you can use a dynamic `import()` in a Server Component to still get automatic `width`, `height`, and `blurDataURL`: + +```tsx filename="app/blog/[slug]/page.tsx" switcher +import Image from 'next/image' + +async function PostImage({ + imageFilename, + alt, +}: { + imageFilename: string + alt: string +}) { + const { default: image } = await import( + `../content/blog/images/${imageFilename}` + ) + // image contains width, height, and blurDataURL + return {alt} +} +``` + +```jsx filename="app/blog/[slug]/page.js" switcher +import Image from 'next/image' + +async function PostImage({ imageFilename, alt }) { + const { default: image } = await import( + `../content/blog/images/${imageFilename}` + ) + // image contains width, height, and blurDataURL + return {alt} +} +``` + +If you have a [path alias](https://www.typescriptlang.org/tsconfig/#paths) configured (e.g. `@/`), you can use it instead of a relative path: + +```tsx +const { default: image } = await import( + `@/content/blog/images/${imageFilename}` +) +``` + +The path must include a static prefix (like `../content/blog/images/`). Be as specific as possible, since **all** files matching that prefix are bundled. Only files in your specified directory are included, so external input cannot reach outside of it. + ## Remote images To use a remote image, you can provide a URL string for the `src` property. diff --git a/docs/01-app/01-getting-started/17-deploying.mdx b/docs/01-app/01-getting-started/17-deploying.mdx index 22af3f6b7bb5..3df41a89880b 100644 --- a/docs/01-app/01-getting-started/17-deploying.mdx +++ b/docs/01-app/01-getting-started/17-deploying.mdx @@ -5,12 +5,12 @@ description: Learn how to deploy your Next.js application. Next.js can be deployed as a Node.js server, Docker container, static export, or adapted to run on different platforms. -| Deployment Option | Feature Support | -| -------------------------------- | ----------------- | -| [Node.js server](#nodejs-server) | All | -| [Docker container](#docker) | All | -| [Static export](#static-export) | Limited | -| [Adapters](#adapters) | Platform-specific | +| Deployment Option | Feature Support | +| -------------------------------- | ------------------------------------------------------------------- | +| [Node.js server](#nodejs-server) | All | +| [Docker container](#docker) | All | +| [Static export](#static-export) | Limited | +| [Adapters](#adapters) | Varies ([verified](#verified-adapters) adapters run the test suite) | ## Node.js server @@ -75,9 +75,20 @@ Running as a [static export](/docs/app/guides/static-exports) **does not** suppo ## Adapters -Next.js can be adapted to run on different platforms to support their infrastructure capabilities. +Next.js can be adapted to run on different platforms to support their infrastructure capabilities. The [Deployment Adapter API](/docs/app/api-reference/config/next-config-js/adapterPath) lets platforms customize how Next.js applications are built and deployed. -Refer to each provider's documentation for information on supported Next.js features: +### Verified Adapters + +Verified adapters are open source, run the full [Next.js compatibility test suite](/docs/app/api-reference/adapters/testing-adapters), and are hosted under the [Next.js GitHub organization](https://github.com/nextjs). The Next.js team coordinates testing with these platforms before major releases. Publicly visible test results for each adapter are coming soon. [Learn more about verified adapters](/docs/app/guides/deploying-to-platforms#verified-adapters). + +- [Vercel](https://vercel.com/docs/frameworks/nextjs) +- [Bun](https://bun.sh/docs/frameworks/nextjs) + +Cloudflare and Netlify are working on verified adapters built on the Adapter API. In the meantime, they offer their own Next.js integrations (see below). + +### Other Platforms + +The following platforms offer their own Next.js integrations. These are not built on the public [Adapter API](/docs/app/api-reference/config/next-config-js/adapterPath) and are not verified by the Next.js team, so feature support and compatibility may vary. Refer to each provider's documentation for details: - [Appwrite Sites](https://appwrite.io/docs/products/sites/quick-start/nextjs) - [AWS Amplify Hosting](https://docs.amplify.aws/nextjs/start/quickstart/nextjs-app-router-client-components) @@ -85,6 +96,5 @@ Refer to each provider's documentation for information on supported Next.js feat - [Deno Deploy](https://docs.deno.com/examples/next_tutorial) - [Firebase App Hosting](https://firebase.google.com/docs/app-hosting/get-started) - [Netlify](https://docs.netlify.com/frameworks/next-js/overview/#next-js-support-on-netlify) -- [Vercel](https://vercel.com/docs/frameworks/nextjs) -> **Note:** We are working on a [Deployment Adapters API](https://github.com/vercel/next.js/discussions/77740) for all platforms to adopt. After completion, we will add documentation on how to write your own adapters. +For details on which Next.js features require specific platform capabilities, see [Deploying to Platforms](/docs/app/guides/deploying-to-platforms). diff --git a/docs/01-app/02-guides/ai-agents.mdx b/docs/01-app/02-guides/ai-agents.mdx index fa5bd1fefa4e..f3f429a921df 100644 --- a/docs/01-app/02-guides/ai-agents.mdx +++ b/docs/01-app/02-guides/ai-agents.mdx @@ -79,6 +79,19 @@ Before any Next.js work, find and read the relevant doc in `node_modules/next/di @AGENTS.md ``` +
+For earlier versions + +On version 16.1 and earlier, use the codemod to generate these files automatically: + +```bash +npx @next/codemod@latest agents-md +``` + +The codemod outputs the bundled docs to `.next-docs/` in the project root instead of `node_modules/next/dist/docs/`, and the generated agent files will point to that directory. + +
+ ## Understanding AGENTS.md The default `AGENTS.md` contains a single, focused instruction: **read the bundled docs before writing code**. This is intentionally minimal — the goal is to redirect agents from stale training data to the accurate, version-matched documentation in `node_modules/next/dist/docs/`. diff --git a/docs/01-app/02-guides/cdn-caching.mdx b/docs/01-app/02-guides/cdn-caching.mdx new file mode 100644 index 000000000000..2b274f5f853c --- /dev/null +++ b/docs/01-app/02-guides/cdn-caching.mdx @@ -0,0 +1,116 @@ +--- +title: Using a CDN with Next.js +nav_title: CDN Caching +description: Learn how CDN caching works with Next.js, including what works today, cache variability, and the direction toward pathname-based cache keying. +related: + description: Related guides and references. + links: + - app/guides/deploying-to-platforms + - app/guides/self-hosting + - app/guides/streaming + - app/api-reference/config/next-config-js/assetPrefix +--- + +Next.js sets standard `Cache-Control` headers that CDNs can use to cache responses at the edge. This page covers what works today, where CDN caching is challenging, and the direction toward eliminating custom-header dependencies. + +## What Works Today + +### Cache-Control headers + +Next.js sets `Cache-Control` headers based on the rendering strategy of each route: + +- **Static pages** (no revalidation): `s-maxage=31536000` (one year) +- **ISR pages** (time-based revalidation): `s-maxage={revalidate}, stale-while-revalidate={expire - revalidate}`. The default `expire` is one year, so `stale-while-revalidate` is included in the response header by default. You can customize this with [`cacheLife`](/docs/app/api-reference/functions/cacheLife). +- **Dynamic pages** (no caching): `private, no-cache, no-store, max-age=0, must-revalidate` + +CDNs that respect `s-maxage` and `stale-while-revalidate` can cache static and ISR pages at the edge. However, CDN-level caching alone does not support on-demand revalidation ([`revalidateTag()`](/docs/app/api-reference/functions/revalidateTag) / [`revalidatePath()`](/docs/app/api-reference/functions/revalidatePath)): those calls invalidate the Next.js server cache, but the CDN will continue serving its cached copy until the `s-maxage` TTL expires. To propagate on-demand revalidation to the CDN, trigger CDN purges alongside your revalidation call. A common pattern is: call `revalidateTag()`/`revalidatePath()` to invalidate the Next.js server cache, then call your CDN purge API for the affected keys (including both HTML and RSC variants). + +### Static assets + +Static assets (JavaScript, CSS, images, fonts) served from `/_next/static/` include content hashes in their filenames and have a 1 year `max-age` and `immutable` directive: `public,max-age=31536000,immutable` + +You can use [`assetPrefix`](/docs/app/api-reference/config/next-config-js/assetPrefix) to serve static assets from a different domain or CDN origin. + +### Static prefetches (PPR-enabled routes) + +When a route has Partial Prerendering enabled and the `next-router-prefetch` header is set (indicating a static prefetch), the response is deterministic: it returns the same prerendered content regardless of the client's router state. The `next-router-state-tree` header is not parsed for these requests, so it does not affect the response. + +For PPR-enabled routes, a CDN can cache static prefetch responses if it: + +1. Includes the `_rsc` search parameter in the cache key (to distinguish prefetch variants from HTML responses). +2. Respects the `Cache-Control` headers Next.js sets on the response. + +> **Good to know:** For routes without PPR, the `next-router-state-tree` header is read during prefetch requests to determine which segments to include, which increases cache `vary` as it passes the current router state. When Cache Components is enabled, segment-level prefetches already use pathname-based routes (for example, `/page.segments/_tree.segment.rsc`), and CDNs can cache these with standard pathname-based cache keys. + +## Where CDN Caching Is Challenging + +App Router responses can vary based on several custom request headers. Next.js sets a `Vary` header on responses to signal this to CDNs: + +- `rsc` — whether the request should return a React Server Components (RSC) payload instead of HTML +- `next-router-state-tree` — the client's current router state, used for targeted segment updates during dynamic navigations +- `next-router-prefetch` — whether this is a prefetch request +- `next-router-segment-prefetch` — the specific segment being prefetched +- `next-url` — added only for routes that use [interception routes](/docs/app/api-reference/file-conventions/intercepting-routes), carries the URL being intercepted + +> **Good to know:** [`proxy.js`](/docs/app/api-reference/file-conventions/proxy) (previously Middleware) should run before the CDN cache so it remains the source of truth for auth, redirects, and rewrites. If your deployment places `proxy.js` behind the CDN, configure the cache layer to bypass caching for routes that depend on `proxy.js` decisions. + +Many CDNs don't support `Vary` without additional configuration. Next.js addresses this with the `_rsc` search parameter: a hash of the relevant request header values that acts as a cache-key, ensuring different response variants get different cache keys. This ensures correct responses even on CDNs that ignore `Vary`. + +## Handling Headers at the CDN + +### What you can safely ignore + +These headers can be omitted in specific cases without causing protocol errors. The server still returns a parseable response, but it may be larger or less targeted to the specific navigation: + +**`next-router-state-tree`**: when omitted on non-prefetch RSC requests, the server returns a full payload instead of a targeted segment update. + +**`next-router-segment-prefetch`**: when omitted on prefetch requests, the server falls back to a broader prefetch payload instead of a segment-specific one. + +**`next-url`**: used for [interception routes](/docs/app/api-reference/file-conventions/intercepting-routes) to vary the response based on the referring page. If omitted, interception routes are not supported as the server doesn't know what original path to match against. The response returned is for regular navigation when `next-url` is omitted: the user sees the target page instead of the intercepted target page. + +### What you must preserve + +**The `rsc` header** must be forwarded from the client to the server. This header tells the server to return an RSC payload instead of HTML. If a CDN strips it, the server returns HTML when the client-side router expects RSC data, which breaks client-side navigation, causing browser navigations instead. The `Vary` header and `_rsc` parameter exist specifically to prevent CDNs from serving a cached HTML response to an RSC request (or vice versa). + +**When `next-router-prefetch` is present, preserve both the prefetch header and the `_rsc` search parameter.** For prefetch flows, `_rsc` is a required cache-busting discriminator and should be treated as mandatory. + +**The `_rsc` search parameter** must be included in the cache key. It distinguishes response variants (HTML vs. RSC, different prefetch types). Ensure your CDN does not strip query parameters from cache keys, as some CDNs do this by default. When the `experimental.validateRSCRequestHeaders` option is enabled and a RSC request arrives without the correct `_rsc` value, the server responds with a **307 redirect** to the URL with the correct hash. CDNs should follow this redirect. Platforms that compute the hash upstream can rewrite requests to include the correct `_rsc` before forwarding to avoid an extra round trip. + +> **Good to know:** Today, `next-url` is included in the `_rsc` hash even during static prefetches. This means you cannot safely ignore it under the current scheme without potentially getting cache misses. The pathname-based direction described below resolves this gap. + +## Direction: Pathname-Based Cache Keying + +The Next.js team is working on moving all cache-affecting inputs into the URL pathname, eliminating the need for `Vary` on custom headers and removing the `_rsc` search parameter. This resolves the CDN caching challenges described above. + +### How it works + +The approach extends the routing scheme that [`output: 'export'`](/docs/app/guides/static-exports) and segment prefetches already use today. File extensions in the pathname identify the response type: + +- **Full page RSC**: `/my/page.rsc` returns the RSC payload for the entire page +- **Segment RSC**: `/my/page.segments/path/to/segment.segment.rsc` returns the RSC payload for a specific segment + +Under this model: + +- **The pathname determines the cache key.** Anything in the pathname affects which response variant is returned. +- **Search parameters can be safely dropped** without affecting returned responses. +- **Standard HTTP cache headers** (`Cache-Control`, `max-age`, etc.) are respected as usual. +- **No `Vary` support needed** from the CDN. + +A CDN would cache Next.js responses by using the pathname as the cache key, ignoring search parameters, and respecting standard `Cache-Control` headers. No need to understand `Vary`, inspect custom headers, or program edge logic. + +### What changes for interception routes + +Under the current scheme, `next-url` contributes to the `_rsc` hash, so dropping it causes cache misses. Under the pathname-based scheme, interception variability would be encoded in a search parameter (not the pathname): + +- If a CDN preserves search params, interception works correctly. +- If a CDN drops search params, interception is not supported. It would gracefully degrade to the non-intercepted page, client-side navigations won't break. + +This makes interception route support an opt-in CDN capability rather than a requirement. + +### Current status + +This direction extends patterns that are already operational in the codebase (segment prefetch paths, `output: 'export'` mode). It is in active design. + +## CDN Feature Compatibility + +For a full table showing the infrastructure primitives available on every major CDN (edge compute, key-value storage, blob storage, PPR resuming), see [Deploying to Platforms](/docs/app/guides/deploying-to-platforms#cdn-infrastructure-compatibility). diff --git a/docs/01-app/02-guides/content-security-policy.mdx b/docs/01-app/02-guides/content-security-policy.mdx index 34793fb77d7d..1763b0c5d57b 100644 --- a/docs/01-app/02-guides/content-security-policy.mdx +++ b/docs/01-app/02-guides/content-security-policy.mdx @@ -534,7 +534,6 @@ module.exports = { ### Limitations of SRI - **Experimental**: Feature may change or be removed -- **Webpack only**: Not available with Turbopack - **App Router only**: Not supported in Pages Router - **Build-time only**: Cannot handle dynamically generated scripts diff --git a/docs/01-app/02-guides/deploying-to-platforms.mdx b/docs/01-app/02-guides/deploying-to-platforms.mdx new file mode 100644 index 000000000000..9b646e995b9b --- /dev/null +++ b/docs/01-app/02-guides/deploying-to-platforms.mdx @@ -0,0 +1,93 @@ +--- +title: Deploying Next.js to different platforms +nav_title: Deploying to Platforms +description: Understand which Next.js features require specific platform capabilities and how to choose the right deployment target. +related: + description: Related guides and references. + links: + - app/guides/rendering-philosophy + - app/guides/self-hosting + - app/getting-started/deploying + - app/api-reference/config/next-config-js/adapterPath +--- + +Next.js [treats static and dynamic content as a spectrum](/docs/app/guides/rendering-philosophy) at the component level. Different features in this model require different platform capabilities. This page helps you understand what your platform needs to support and how to configure your deployment. + +## Minimum Requirements + +To run Next.js, your platform needs **a Node.js server**. That's it. + +A single `next start` process handles every Next.js feature correctly: Server Components, ISR, PPR, Cache Components, Server Actions, Proxy, and `after()`. Streaming support is needed for features like PPR and Server Components to deliver content progressively (without it, responses are buffered and sent as a whole, which still works but loses the streaming performance benefit). Additional infrastructure (CDN caching, edge compute, shared cache) primarily improves performance and multi-instance consistency. In multi-instance deployments, shared cache and tag coordination reduce stale divergence between instances. The only additional dependency is the `sharp` package, which is required for [Image Optimization](/docs/app/api-reference/components/image). + +## Functional Fidelity vs. Performance Fidelity + +When evaluating platform support for Next.js, it helps to distinguish between two levels: + +**Functional fidelity** means every Next.js feature works correctly. The [adapter test suite](/docs/app/api-reference/adapters/testing-adapters) is the contract: if a platform's adapter passes the tests, it supports Next.js. This is binary: it passes or it doesn't. + +**Performance fidelity** means features achieve their optimal performance characteristics. Examples include PPR's static shell served at CDN latency rather than origin latency, or ISR serving stale content instantly with sub-second revalidation propagation. Performance fidelity is a spectrum that every platform will achieve differently based on their architecture. + +A platform that achieves functional fidelity is a fully supported deployment target for Next.js. Performance fidelity is how platforms differentiate, and it improves incrementally over time. + +Use the feature matrix below through this lens: "Streaming Required" and "Shared Cache Recommended" describe what is needed for functional fidelity. "Edge Stitching" is a performance fidelity optimization. + +## Feature Support Matrix + +Different features require different infrastructure capabilities. The "Edge Stitching" column is a **performance optimization**, not a correctness requirement. All features work correctly from a single origin server. + +| Feature | Streaming | Shared Cache | Edge Stitching | Notes | +| ------------------------------ | --------- | ------------ | -------------- | ------------------------------------------------------------------------------------------------ | +| Server Components | Required | No | No | Basic streaming support | +| ISR (time-based) | No | Recommended | No | Works per-instance without shared cache | +| ISR (on-demand) | No | Recommended | No | [Tag propagation](/docs/app/guides/how-revalidation-works) needs shared cache for multi-instance | +| Partial Prerendering | Required | Recommended | Optional | [See PPR Platform Guide](/docs/app/guides/ppr-platform-guide) | +| Cache Components (`use cache`) | Required | Recommended | No | Shared cache enables cross-instance consistency | +| Proxy / Middleware | No | No | No | Runs at edge or origin | +| Server Actions | Required | No | No | POST requests with streaming response | +| `after()` | No | No | No | Requires [graceful shutdown](/docs/app/guides/self-hosting#after) support | + +**Streaming Required** means the platform must support chunked transfer encoding or HTTP/2 streaming and must not buffer the response before sending it to the client. + +**Shared Cache Recommended** means multiple server instances benefit from shared cache backends to coordinate. For ISR and server response caching, use [`cacheHandler`](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath). For `'use cache'` entries, use [`cacheHandlers`](/docs/app/api-reference/config/next-config-js/cacheHandlers). Without shared cache, each instance maintains its own cache independently — features still work correctly on each instance, but revalidation events don't propagate across instances. + +## CDN Infrastructure Compatibility + +The following table maps infrastructure primitives for each major CDN. These are available building blocks, not finished integrations: + +| CDN | Edge Compute | Key-Value / Tags | Blob Storage | PPR Resuming | +| ----------------- | ------------ | ---------------- | -------------- | ------------ | +| Cloudflare | Workers | KV | R2 | Yes (worker) | +| Akamai | EdgeWorkers | EdgeKV | Object Storage | Yes (worker) | +| Amazon CloudFront | Lambda@Edge | KeyValueStore | S3 | Yes (Lambda) | +| Fastly | Compute | KV Store | Object Storage | Yes (WASM) | +| Azure | Functions | Managed Redis | Blob Storage | Yes (server) | +| Google Cloud | Cloud Run | Various KV | Cloud Storage | Yes (server) | + +These are available building blocks, not finished integrations. Most community adapters today deploy Next.js as a Docker container or Node.js server without leveraging CDN-specific primitives like edge KV or PPR resuming. See the [Deploying](/docs/app/getting-started/deploying#adapters) page for the current list of adapters. For CDN-specific caching considerations (including known limitations with `Vary` on custom headers), see [CDN Caching](/docs/app/guides/cdn-caching). + +## Adapters + +Next.js provides a [Deployment Adapter API](/docs/app/api-reference/config/next-config-js/adapterPath) that lets platforms customize how Next.js applications are built and deployed for their infrastructure. Adapters run at build time and produce platform-specific output from the standard Next.js build. Anyone can build an adapter using the public API with no special access required. + +The adapter API plus Next.js caching interfaces form the complete platform integration surface. The adapter handles build-time output, while `cacheHandler` and `cacheHandlers` cover different runtime caching paths. `cacheHandler` (singular) covers server cache paths like ISR, route handlers, patched `fetch`/`unstable_cache`, and image optimization. `cacheHandlers` (plural) configures `'use cache'` directive backends. + +### Verified Adapters + +A **verified adapter** is one that meets two requirements: + +1. **Open source.** The adapter source code is publicly available so the community and the Next.js team can inspect, contribute to, and verify it. +2. **Runs the compatibility test suite.** The platform provides a way to run the full [Next.js compatibility test suite](/docs/app/api-reference/adapters/testing-adapters) against their adapter. This gives visibility into which features work, which are in progress, and where gaps remain. + +Verified adapters are hosted under the [Next.js GitHub organization](https://github.com/nextjs), listed as supported deployment targets in the Next.js documentation, and maintained by their respective platform teams. There are no private framework hooks or integration paths: Vercel's adapter uses the same public API as every other adapter. + +For verified adapters and platforms working toward verified status through the [Ecosystem Working Group](https://nextjs.org/ecosystem-working-group), the Next.js team commits to: + +- **Coordinated testing.** Before major releases, we work with platform teams to run the compatibility test suite and surface issues early. +- **Early access.** Adapter authors receive early access to API changes during RFCs and release candidates. +- **Direct support.** When the adapter contract needs updating, we work directly with adapter teams. + +> **Good to know:** Platforms can build closed-source adapters on the same public API and test suite. However, closed-source adapters will not be listed as verified, since the Next.js team cannot verify what it cannot inspect. + +## A Note on Infrastructure Requirements + +Next.js's [rendering model](/docs/app/guides/rendering-philosophy) places the static/dynamic boundary at the component level rather than the route level. Finer-grained boundaries provide more flexibility for developers at the cost of broader requirements for hosting platforms. This is a deliberate trade-off: the infrastructure requirements on this page exist because of what the rendering model delivers. diff --git a/docs/01-app/02-guides/how-revalidation-works.mdx b/docs/01-app/02-guides/how-revalidation-works.mdx new file mode 100644 index 000000000000..0ba69a127f5e --- /dev/null +++ b/docs/01-app/02-guides/how-revalidation-works.mdx @@ -0,0 +1,96 @@ +--- +title: How revalidation works in Next.js +nav_title: How Revalidation Works +description: A deep dive into how Next.js revalidates cached content, including the tag system, cache consistency, and multi-instance coordination. +related: + description: Related guides and references. + links: + - app/getting-started/caching + - app/guides/incremental-static-regeneration + - app/api-reference/config/next-config-js/cacheHandlers + - app/api-reference/functions/revalidateTag +--- + +The [Caching](/docs/app/getting-started/caching) page covers how to use `use cache`, `cacheTag`, and `cacheLife`. This page explains **how revalidation works internally**, for platform engineers and advanced users who need to understand the system to implement [custom cache handlers](/docs/app/api-reference/config/next-config-js/cacheHandlers) or debug revalidation behavior. + +## The Revalidation Model + +Most routes in Next.js can be revalidated on demand. This includes App Router routes and Pages Router routes that produce ISR/prerender cache entries. Pages Router routes that are automatically statically optimized (pure static output) are not revalidated on demand. The ability to update cached content without redeploying is a core part of Next.js's [rendering model](/docs/app/guides/rendering-philosophy). + +There are two types of revalidation: + +- **Time-based revalidation** uses a stale-while-revalidate pattern. The cached content is served immediately, and a background regeneration is triggered when the content's age exceeds the [`cacheLife`](/docs/app/api-reference/functions/cacheLife) or `revalidate` duration. The stale content continues to be served until the fresh content is ready. +- **On-demand revalidation** explicitly invalidates cached content by calling [`revalidateTag()`](/docs/app/api-reference/functions/revalidateTag) or [`revalidatePath()`](/docs/app/api-reference/functions/revalidatePath). The next request to that content triggers a fresh render. + +> **Good to know:** Pages Router on-demand ISR APIs (for example `res.revalidate()` and the `x-prerender-revalidate` flow) are still supported and use the server cache handler (`cacheHandler`, singular). The `cacheHandlers` option (plural) is for `'use cache'` directives. + +## What Gets Revalidated + +When a route is revalidated, Next.js regenerates **both** the HTML response and the RSC payload (React Server Components payload) from the same React component tree. Both artifacts are stored together in the same cache entry. + +This consistency matters because the RSC payload is used for client-side navigations. Browser navigations and client-side navigations should hold the same content. + +### What happens if they get out of sync + +If a platform's cache serves HTML from one render and an RSC payload from a different render, users may see stale or mismatched content during client-side navigation. The primary mitigation is to cache HTML and RSC responses together with the same TTL and invalidation policy, and to respect the [`Vary` header](/docs/app/guides/cdn-caching) that Next.js sets. See [CDN Caching](/docs/app/guides/cdn-caching) for details. + +A separate but related problem is **cross-deployment skew**: during rolling deployments, a client built with deploy A may receive responses from a server running deploy B. [`deploymentId`](/docs/app/api-reference/config/next-config-js/deploymentId) mitigates this: when the client detects a different deployment ID from the server, it triggers a hard navigation to fetch consistent content. + +## Tag System Architecture + +Next.js uses a tag-based system to track which cached content needs to be invalidated. There are two types of tags: + +### Explicit tags + +Explicit tags are set by the developer using [`cacheTag()`](/docs/app/api-reference/functions/cacheTag) inside a `use cache` function, or via `next: { tags: [...] }` on a `fetch` call. When [`revalidateTag('my-tag')`](/docs/app/api-reference/functions/revalidateTag) is called, all cache entries with that tag are invalidated. + +### Soft tags + +Soft tags are automatically generated by Next.js based on the route path, prefixed with `_N_T_`. For example, the route `/blog/hello` generates soft tags like `_N_T_/layout`, `_N_T_/blog/layout`, `_N_T_/blog/hello/layout`, and `_N_T_/blog/hello`. Each segment in the path gets a layout tag, plus the leaf route itself. + +Soft tags enable [`revalidatePath()`](/docs/app/api-reference/functions/revalidatePath) to work through the same tag-based system. When `revalidatePath('/blog/hello')` is called, it invalidates cache entries associated with that path's leaf route tag and its ancestor layout soft tags (for example `_N_T_/layout`, `_N_T_/blog/layout`, `_N_T_/blog/hello/layout`, and `_N_T_/blog/hello`). + +In the [cache handler API](/docs/app/api-reference/config/next-config-js/cacheHandlers), soft tags are passed to the `get()` method as the `softTags` parameter. Your handler should check whether any soft tag has been invalidated after the cache entry's timestamp. The `getExpiration()` method returns the most recent revalidation timestamp across all provided tags, or `0` if none have been revalidated. Your handler should treat an entry as stale if the returned timestamp is newer than the entry's own timestamp. See the [cache handler API reference](/docs/app/api-reference/config/next-config-js/cacheHandlers#getexpiration) for the full semantics. + +## Multi-Instance Considerations + +When running multiple Next.js instances behind a load balancer, revalidation events are local by default. Calling `revalidateTag()` on instance A only invalidates the cache on that instance. Other instances continue serving the stale content until they learn about the invalidation. + +The cache handler API provides two hooks for distributed coordination: + +- **`updateTags()`** is called when `revalidateTag()` is invoked. Your handler should write the invalidation event to shared storage (for example, Redis or a database) so other instances can discover it. +- **`refreshTags()`** is called periodically, but always before starting a new request. Your handler should check shared storage for recent invalidation events and update its local tag state accordingly. + +For implementation details and a Redis example, see [Custom Cache Handlers](/docs/app/api-reference/config/next-config-js/cacheHandlers). + +## Implementation Patterns for Platforms + +### Single instance + +The default file-system cache handles consistency automatically. Cache writes are atomic on the local filesystem, and tag state is maintained in memory. No additional configuration is needed. + +### Multi-instance with shared cache + +Without coordination, each instance independently serves content and handles revalidation using only its local cache. Different users may see different content depending on which instance serves their request, and on-demand revalidation only takes effect on the instance that received the call. + +To reduce this window and ensure revalidation propagates across instances: + +1. Store tag invalidation timestamps in a shared service (Redis, DynamoDB, or a simple HTTP API). +2. Implement `updateTags()` to write to the shared service. +3. Implement `refreshTags()` to read from the shared service. Your handler must catch errors in `refreshTags()`: if it throws, the exception propagates as a request failure. Catching the error allows requests to continue with the last known local tag state, serving potentially stale content until connectivity is restored. +4. Store cache entries (HTML + RSC payload) in shared storage. Atomic writes reduce the mismatch window further but are not required for correctness. + +### CDN integration + +If a CDN caches Next.js responses, it should respect the `Vary` header and the `Cache-Control` directives that Next.js sets. Do not cache HTML and RSC payload responses separately with different TTLs. See [CDN Caching](/docs/app/guides/cdn-caching) for details. + +## Graceful Degradation + +The revalidation system prioritizes availability over strict consistency. Content is always served, even when infrastructure guarantees cannot be fully met: + +- **Cache write failure**: the response is still served to the user because writes are asynchronous. The cache entry is lost, and the next request triggers a fresh render. +- **Cache read failure**: your handler should catch internal errors and return `undefined` (the cache miss signal). The route is then server-rendered fresh. The framework does not wrap `get()` in a try/catch, so unhandled exceptions will propagate as render errors. +- **HTML/RSC cache inconsistency**: if a CDN caches HTML and RSC responses with different TTLs or invalidation timing, users may see mismatched content during client-side navigation. Cache them together and respect the `Vary` header to avoid this. +- **Cross-deployment skew**: during rolling deployments, configure [`deploymentId`](/docs/app/api-reference/config/next-config-js/deploymentId) so that a build ID change triggers a hard navigation to fetch consistent content. + +Cache failures result in degraded performance (stale content, extra renders), not broken applications. diff --git a/docs/01-app/02-guides/incremental-static-regeneration.mdx b/docs/01-app/02-guides/incremental-static-regeneration.mdx index f087455c48bd..e031f1c3fd13 100644 --- a/docs/01-app/02-guides/incremental-static-regeneration.mdx +++ b/docs/01-app/02-guides/incremental-static-regeneration.mdx @@ -575,6 +575,9 @@ This will make the Next.js server console log ISR cache hits and misses. You can - If you have multiple `fetch` requests in a prerendered route, and each has a different `revalidate` frequency, the lowest time will be used for ISR. However, those revalidate frequencies will still be respected by the [cache](/docs/app/getting-started/caching). - If any of the `fetch` requests used on a route have a `revalidate` time of `0`, or an explicit `no-store`, the route will be dynamically rendered. - Proxy won't be executed for on-demand ISR requests, meaning any path rewrites or logic in Proxy will not be applied. Ensure you are revalidating the exact path. For example, `/post/1` instead of a rewritten `/post-1`. +- When running multiple instances, the default file-system cache is per-instance. On-demand revalidation only invalidates the instance that receives the call. Use a shared [custom cache handler](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath) to coordinate across instances. See [How Revalidation Works](/docs/app/guides/how-revalidation-works) for the full architecture. +- Background regeneration (stale-while-revalidate) runs on the instance that receives the triggering request. On platforms with per-request billing, this background work counts as additional compute. +- You can use the `x-nextjs-cache` response header to observe cache behavior. Values are `HIT` (served from cache), `STALE` (served from cache, revalidating in background), `MISS` (not in cache, rendered fresh), or `REVALIDATED` (regenerated via on-demand revalidation). diff --git a/docs/01-app/02-guides/ppr-platform-guide.mdx b/docs/01-app/02-guides/ppr-platform-guide.mdx new file mode 100644 index 000000000000..8bd084ba0dc7 --- /dev/null +++ b/docs/01-app/02-guides/ppr-platform-guide.mdx @@ -0,0 +1,114 @@ +--- +title: Implementing Partial Prerendering on your platform +nav_title: PPR Platform Guide +description: A guide for platform engineers on implementing PPR support, from basic origin rendering to optimized CDN integration. +related: + description: Related guides and references. + links: + - app/guides/rendering-philosophy + - app/guides/streaming + - app/guides/deploying-to-platforms + - app/api-reference/config/next-config-js/adapterPath +--- + +Partial Prerendering (PPR) combines static and dynamic rendering in a single route. At build time, Next.js generates a static HTML shell and a `postponedState` blob for each PPR-enabled route. At request time, the shell is served immediately and dynamic portions are rendered and streamed to the client. + +This page explains how platforms can implement PPR support at different levels of sophistication. + +## How PPR Works + +### Build time + +For each PPR route, Next.js produces: + +- A **static HTML shell** containing all the content that can be prerendered, with [Suspense](/docs/app/guides/streaming#what-is-streaming) fallbacks where dynamic content will appear. +- A **`postponedState`** value: a serialized string. Treat it as opaque: pass it through without parsing or modifying it. Altering `postponedState` produces incorrect dynamic rendering output. +- An **RSC payload** for the static portions of the page. + +### Request time + +When a request arrives for a PPR route: + +1. The server sends the static HTML shell to the client immediately. +2. The server resumes rendering the dynamic portions using the postponed state. +3. Dynamic content is streamed to the client, allowing React to hydrate the deferred Suspense boundaries. + +The client sees the static shell instantly, then dynamic content appears as it resolves. + +## Storing PPR Artifacts + +Each PPR route requires two artifacts to be stored together: + +1. The static HTML shell. +2. The `postponedState` blob. + +These must be stored and updated atomically. When a PPR route is revalidated (via [time-based](/docs/app/guides/incremental-static-regeneration) or [on-demand revalidation](/docs/app/api-reference/functions/revalidateTag)), Next.js regenerates both the shell and the postponed state together. Serving a new shell with an old postponed state, or vice versa, will produce incorrect dynamic content. + +Use [`requestMeta.onCacheEntryV2`](/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter) in your adapter to observe cache updates and propagate them to your storage backend. + +## Origin-Only Implementation + +**This is the simplest approach and works on every platform that supports streaming HTTP responses.** + +All requests go directly to the Next.js server. The server reads the shell from its local cache, sends it, then renders and streams the dynamic content. This is what `next start` does by default. + +No additional infrastructure is needed. If your platform supports streaming HTTP responses, it supports PPR. + +## CDN Shell + Origin Compute + +For better TTFB, the static HTML shell can be cached at the CDN edge. When a request arrives: + +1. The CDN serves the cached shell immediately (edge latency). +2. The CDN sends a resume request to the origin server (ideally in parallel with streaming the shell). +3. The origin server renders only the dynamic portions and streams them back. +4. The CDN concatenates the shell and dynamic content into a single streaming response to the client. + +This requires the CDN to support a mechanism for combining cached and dynamic content in a single streaming response. The static shell TTFB drops to edge latency while dynamic content still streams from origin. + +For the lowest possible latency, the shell can be served from edge storage (for example, a KV store populated during `onBuildComplete`) rather than from a CDN cache. This is a platform architecture decision and does not require any changes to the Next.js application. + +## The Resume Protocol + +The **resume protocol** tells the Next.js handler to skip the shell and render only the dynamic portions. It is used by CDN-to-origin architectures and adapter-based deployments that serve the shell separately. + +In standard `next start`, the server handles both the shell and dynamic render in a single pass automatically. + +### CDN-to-origin + +When the CDN makes an HTTP request to a separate Next.js origin: + +- Send a **POST** request to the route with the header `next-resume: 1`. +- Include the `postponedState` blob as the **request body**. +- The server will render only the deferred Suspense boundaries and stream the result. + +> **Good to know:** When a POST request combines a Server Action with a PPR resume, the request body contains the postponed state followed by the action body. The `x-next-resume-state-length` header carries the byte length of the postponed state prefix so the handler can separate the two. For a pure PPR resume (the common case), the entire request body is the postponed state and this header is not needed. + +### Adapter-based + +When the platform invokes the handler function directly: + +- Call the entrypoint handler with `req.method` set to `'POST'`, the `next-resume: 1` header on the request, and the `postponedState` as the request body. (You can also pass `requestMeta: { postponed: postponedState }` as the third argument to the handler invocation, which is equivalent but bypasses the HTTP layer entirely.) +- The handler renders only the deferred Suspense boundaries and streams the result to `res`. +- No HTTP round-trip is needed: the handler is invoked in-process. + +### Finding PPR routes in build output + +In the [adapter output](/docs/app/api-reference/adapters/output-types), PPR routes are identified by `renderingMode: 'PARTIALLY_STATIC'` in the prerenders array. Iterate `outputs.prerenders` to find these entries and read `fallback.postponedState`. + +`pprChain.headers` contains the headers needed for the resume protocol: `{ 'next-resume': '1' }`. + +For detailed implementation with code examples, see [Implementing PPR in an Adapter](/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter). + +## Implementation Checklist + +1. **Read PPR outputs at build time.** In your adapter's `onBuildComplete`, identify prerenders with `renderingMode: 'PARTIALLY_STATIC'`. Store the shell HTML and `postponedState` in your cache. + +2. **Serve the shell at request time.** For incoming requests to PPR routes, serve the cached shell immediately and begin streaming. + +3. **Resume dynamic rendering.** For CDN-to-origin: send a POST request to the Next.js handler with the `next-resume: 1` header and the postponed state as the body. For adapter-based: call the handler directly with POST method and the postponed state in the request body (or pass `requestMeta: { postponed: postponedState }` to the handler). Stream the response back to the client. + +4. **Handle cache updates.** Use `requestMeta.onCacheEntryV2` to capture new shell + postponed state pairs after revalidation, and update your cache atomically. + +5. **Support graceful degradation.** If the postponed state is unavailable or stale, fall back to a full server render. The user gets a complete page without the shell-first optimization. + +For the complete adapter API reference and implementation examples, see the [Deployment Adapter API](/docs/app/api-reference/config/next-config-js/adapterPath). diff --git a/docs/01-app/02-guides/preserving-ui-state.mdx b/docs/01-app/02-guides/preserving-ui-state.mdx index be97f907f407..d7026a7c098d 100644 --- a/docs/01-app/02-guides/preserving-ui-state.mdx +++ b/docs/01-app/02-guides/preserving-ui-state.mdx @@ -496,7 +496,7 @@ The ref persists across hide/show cycles (refs aren't cleaned up), so `hasMounte ## Examples -The [Activity Patterns Demo](https://react-activity-patterns.vercel.app/) ([source](https://github.com/vercel-labs/react-activity-patterns)) is a Next.js app with Cache Components enabled and three routes. Navigate between them to see state preservation in action: +The [Activity Patterns Demo](https://react-activity-patterns.labs.vercel.dev) ([source](https://github.com/vercel-labs/react-activity-patterns)) is a Next.js app with Cache Components enabled and three routes. Navigate between them to see state preservation in action: - **Data** — sortable table and selectable list that keep their state across navigations, plus a reviews section that prerenders in the background - **Forms** — filter panel with DOM state (`
`, checkboxes, text inputs) that persists, and a newsletter form that resets after submission using `useLayoutEffect` cleanup diff --git a/docs/01-app/02-guides/rendering-philosophy.mdx b/docs/01-app/02-guides/rendering-philosophy.mdx new file mode 100644 index 000000000000..0ef4069ca725 --- /dev/null +++ b/docs/01-app/02-guides/rendering-philosophy.mdx @@ -0,0 +1,75 @@ +--- +title: Next.js Rendering Philosophy +nav_title: Rendering Philosophy +description: Learn how Next.js treats static and dynamic rendering as a spectrum at the component level, and what this means for deployment. +related: + description: Learn more about the features discussed on this page. + links: + - app/getting-started/caching + - app/guides/streaming + - app/guides/self-hosting + - app/guides/deploying-to-platforms +--- + +## Static and Dynamic as a Spectrum + +Most web frameworks draw a hard line between static and dynamic at the route level. A page is either prerendered at build time or server-rendered at request time. This model is simple to understand and simple to deploy: you upload static files to a CDN and point dynamic routes at a server. + +Next.js takes a different approach: **the boundary between static and dynamic is at the component level, not the route level.** A single page can have a static shell that loads instantly and dynamic sections that stream in as they resolve. A cached function can live inside a dynamic route. A static page can be updated without a redeploy. + +This is what Partial Prerendering, [Cache Components](/docs/app/getting-started/caching) (`use cache`), and [on-demand revalidation](/docs/app/api-reference/functions/revalidateTag) enable. They are not incremental features: they represent a rendering model that treats static and dynamic as a spectrum rather than a binary choice. + +## What This Enables + +This model benefits developers and users in concrete ways: + +- **Faster perceived load times.** The static shell renders immediately while dynamic content streams in. Users see useful content right away instead of waiting for the entire page to render. +- **Incremental caching.** Developers can add caching and revalidation incrementally, without deciding upfront at build time whether a route is static or dynamic. Any page can be revalidated on demand, and any function can be cached with [`use cache`](/docs/app/api-reference/directives/use-cache). +- **Granular caching.** Cache a function with [`use cache`](/docs/app/api-reference/directives/use-cache), not a route. Revalidate a [tag](/docs/app/api-reference/functions/revalidateTag), not a deployment. This means an expensive database query can be cached independently of the rest of the page. + +## The Trade-Off + +Web frameworks differ in where they place the boundary between static and dynamic content. Each approach makes a different trade-off between developer flexibility and infrastructure complexity. + +### Build-time prerendering + +Every page is generated at build time. The output is static files that can be served from any CDN or file server with zero runtime infrastructure. Dynamic content, if any, requires client-side fetching after the page loads. This is the simplest model to deploy, but every content change requires a rebuild and redeploy. + +### Route-level boundaries + +Each route chooses whether it is static or dynamic. Static routes are prerendered at build time, dynamic routes are server-rendered per request. The infrastructure splits cleanly: static files go to a CDN, dynamic routes go to a server. This is straightforward to reason about but the choice is all-or-nothing per route. A mostly-static page with one dynamic element (a user greeting, a live price) must either be fully dynamic or fetch that element on the client after load. + +### Component-level boundaries + +This is the approach Next.js takes. Static and dynamic content coexist within a single streaming response. A page can have a static shell that loads instantly, a cached function that revalidates independently, and a dynamic section that streams in as it resolves, all without the developer splitting anything into separate routes or client-side fetches. + +The trade-off is infrastructure complexity. A finer-grained rendering boundary transfers complexity from application code into the hosting platform. The infrastructure requirements described below exist because of this choice. + +## Infrastructure Implications + +The component-level rendering model has direct implications for platforms hosting Next.js applications: + +- **Streaming** is required because static and dynamic content are served in a single response. The server sends initial content first, then streams dynamic portions as they resolve. See [Streaming](/docs/app/guides/streaming) for details. +- **Cache coordination** is required when running multiple instances because any cached content can be invalidated on demand via [`revalidateTag()`](/docs/app/api-reference/functions/revalidateTag) or [`revalidatePath()`](/docs/app/api-reference/functions/revalidatePath). See [How Revalidation Works](/docs/app/guides/how-revalidation-works) for the architecture. +- **Cache consistency** matters because revalidation regenerates both the HTML response and the RSC payload (the serialized React Server Components data used for client-side navigation). If these get out of sync, users may see inconsistent data during navigation. See [How Revalidation Works](/docs/app/guides/how-revalidation-works) for consistency requirements. +- **PPR shell delivery at CDN latency** can require additional platform integration to store the static shell separately and resume dynamic rendering correctly. See [PPR Platform Guide](/docs/app/guides/ppr-platform-guide) for implementation details. + +Each of these infrastructure requirements maps directly to a capability: streaming enables progressive delivery, cache coordination propagates invalidations across instances, cache consistency keeps HTML and RSC aligned, and PPR-at-edge often requires extra shell/resume integration. + +## Portability and Fidelity + +Next.js runs as a Node.js server process, and a single process handles every feature correctly. Streaming support enables progressive delivery of Server Components and PPR; without it, responses are buffered but features still work. Additional infrastructure investments (CDN caching, edge compute, shared cache) improve performance and, in multi-instance deployments, reduce consistency gaps. + +To make this concrete, we distinguish between two types of platform support: + +**Functional fidelity** means every Next.js feature works correctly on the platform. The [adapter test suite](/docs/app/api-reference/adapters/testing-adapters) is the contract: if a platform's adapter passes the tests, it has full functional fidelity. This is binary: it passes or it doesn't. The test suite is open to contributions from platform partners to ensure it is fair and complete. + +**Performance fidelity** means features achieve their optimal performance characteristics. For example, PPR's static shell served at CDN latency rather than origin latency, or ISR serving stale content instantly while revalidating in the background with sub-second propagation. Performance fidelity is a spectrum: every platform will achieve different levels based on their architecture, and platforms will improve over time. + +A platform that achieves functional fidelity is a fully supported deployment target for Next.js. Performance fidelity is how platforms differentiate. See [Deploying to Platforms](/docs/app/guides/deploying-to-platforms) for the full feature compatibility matrix. + +## CDN Feature Compatibility + +Many CDNs have useful primitives for deeper Next.js integration (edge compute, key-value storage, blob storage), but end-to-end PPR resume support is still emerging and may require bespoke platform work. Most community adapters today deploy Next.js as a Node.js server without leveraging these CDN-specific primitives. See the [Deploying](/docs/app/getting-started/deploying#adapters) page for the current list of adapters. + +See [Deploying to Platforms](/docs/app/guides/deploying-to-platforms#cdn-infrastructure-compatibility) for the full CDN compatibility table and [CDN Caching](/docs/app/guides/cdn-caching) for caching behavior details. diff --git a/docs/01-app/02-guides/self-hosting.mdx b/docs/01-app/02-guides/self-hosting.mdx index a2ca93e336f6..d0273b919fa9 100644 --- a/docs/01-app/02-guides/self-hosting.mdx +++ b/docs/01-app/02-guides/self-hosting.mdx @@ -88,14 +88,16 @@ This allows you to use a singular Docker image that can be promoted through mult Next.js can cache responses, generated static pages, build outputs, and other static assets like images, fonts, and scripts. -Caching and revalidating pages (with [Incremental Static Regeneration](/docs/app/guides/incremental-static-regeneration)) use the **same shared cache**. By default, this cache is stored to the filesystem (on disk) on your Next.js server. **This works automatically when self-hosting** using both the Pages and App Router. +Caching and revalidating pages (with [Incremental Static Regeneration](/docs/app/guides/incremental-static-regeneration)) use the **same Next.js server cache**. By default, this cache is stored on the local filesystem (on disk) of each Next.js server instance. + +This works automatically for a single self-hosted `next start` instance with persistent local disk. If you run multiple instances, use ephemeral compute, or place a CDN/reverse proxy in front of Next.js, also review [Configuring Caching](#configuring-caching), [Multi-Instance Cache Coordination](#multi-instance-cache-coordination), and [Usage with CDNs](#usage-with-cdns). You can configure the Next.js cache location if you want to persist cached pages and data to durable storage, or share the cache across multiple containers or instances of your Next.js application. ### Automatic Caching - Next.js sets the `Cache-Control` header of `public, max-age=31536000, immutable` to truly immutable assets. It cannot be overridden. These immutable files contain a SHA-hash in the file name, so they can be safely cached indefinitely. For example, [Static Image Imports](/docs/app/getting-started/images#local-images). You can [configure the TTL](/docs/app/api-reference/components/image#minimumcachettl) for images. -- Incremental Static Regeneration (ISR) sets the `Cache-Control` header of `s-maxage: , stale-while-revalidate`. This revalidation time is defined in your [`getStaticProps` function](/docs/pages/building-your-application/data-fetching/get-static-props) in seconds. If you set `revalidate: false`, it will default to a one-year cache duration. +- Incremental Static Regeneration (ISR) sets the `Cache-Control` header of `s-maxage: , stale-while-revalidate`. This revalidation time is defined in your [`getStaticProps` function](/docs/pages/building-your-application/data-fetching/get-static-props) in seconds. If you set `revalidate: false`, it will default to a one-year cache duration. To leverage this at the CDN layer, your CDN/reverse proxy must respect these directives and cache-key variability ([CDN Caching](/docs/app/guides/cdn-caching)); otherwise, responses may bypass CDN caching or serve stale/mismatched variants during client-side navigation. - Dynamically rendered pages set a `Cache-Control` header of `private, no-cache, no-store, max-age=0, must-revalidate` to prevent user-specific data from being cached. This applies to both the App Router and Pages Router. This also includes [Draft Mode](/docs/app/guides/draft-mode). ### Static Assets @@ -106,10 +108,12 @@ If you want to host static assets on a different domain or CDN, you can use the ### Configuring Caching -By default, generated cache assets will be stored in memory (defaults to 50mb) and on disk. If you are hosting Next.js using a container orchestration platform like Kubernetes, each pod will have a copy of the cache. To prevent stale data from being shown since the cache is not shared between pods by default, you can configure the Next.js cache to provide a cache handler and disable in-memory caching. +By default, generated cache assets will be stored in memory (defaults to 50mb) and on disk. On ephemeral compute platforms (common serverless setups), local disk is often non-persistent or unavailable, so this cache is effectively short-lived and per-instance. If you are hosting Next.js using a container orchestration platform like Kubernetes, each pod will have a copy of the cache. To prevent stale data from being shown since the cache is not shared between pods by default, you can configure the Next.js cache to provide a cache handler and disable in-memory caching. To configure the cache location when self-hosting, you can configure a custom handler in your `next.config.js` file: +For production deployments, use this as a starting point and extend it with durable storage, eviction policies, error handling, and distributed tag coordination. See [Custom Next.js Cache Handler](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath) and the [Redis `cacheHandler` example](https://github.com/vercel/next.js/tree/canary/examples/cache-handler-redis). If you are configuring backends for `'use cache'` directives, use [`cacheHandlers`](/docs/app/api-reference/config/next-config-js/cacheHandlers). + ```jsx filename="next.config.js" module.exports = { cacheHandler: require.resolve('./cache-handler.js'), @@ -256,6 +260,22 @@ module.exports = { } ``` +Beyond nginx, ensure that your entire infrastructure supports streaming end-to-end: + +- **Load balancers** must support chunked transfer encoding or HTTP/2 streaming. Some cloud load balancers (for example, AWS ALB with Lambda integration) may buffer responses by default. +- **Reverse proxies** between the load balancer and Next.js must also pass through chunked responses without buffering. +- If using [Partial Prerendering](/docs/app/guides/ppr-platform-guide), streaming support is required. Without it, the static shell and dynamic content are delivered together after the full render completes, eliminating PPR's time-to-first-byte advantage. + +## Multi-Instance Cache Coordination + +In addition to the [multi-server configuration](/docs/app/guides/self-hosting#multi-server-deployments) above (encryption key, deployment ID, shared cache), App Router deployments with multiple instances need cache tag coordination. + +By default, calling [`revalidateTag()`](/docs/app/api-reference/functions/revalidateTag) on one instance only invalidates the cache on that instance. Other instances continue serving stale content until they independently discover the invalidation. + +To coordinate tag invalidation across instances, implement the [`refreshTags()`](/docs/app/api-reference/config/next-config-js/cacheHandlers#refreshtags) method in your [custom cache handler](/docs/app/api-reference/config/next-config-js/cacheHandlers). This method is called before each request and should sync tag state from shared storage (like Redis) so all instances learn about invalidations promptly. + +For a detailed explanation of the tag architecture, see [How Revalidation Works](/docs/app/guides/how-revalidation-works). + ## Cache Components [Cache Components](/docs/app/getting-started/caching) works by default with Next.js and is not a CDN-only feature. This includes deployment as a Node.js server (through `next start`) and when used with a Docker container. @@ -266,7 +286,7 @@ When using a CDN in front of your Next.js application, the page will include `Ca If you don't need a mix of both static and dynamic components, you can make your entire route static and cache the output HTML on a CDN. This Automatic Static Optimization is the default behavior when running `next build` if dynamic APIs are not used. -As Partial Prerendering moves to stable, we will provide support through the Deployment Adapters API. +For detailed guidance on CDN caching behavior, graceful degradation, and cache variability, see [CDN Caching](/docs/app/guides/cdn-caching). For Partial Prerendering support on different platforms, see the [PPR Platform Guide](/docs/app/guides/ppr-platform-guide) and the [Deployment Adapter API](/docs/app/api-reference/config/next-config-js/adapterPath). @@ -276,7 +296,7 @@ As Partial Prerendering moves to stable, we will provide support through the Dep [`after`](/docs/app/api-reference/functions/after) is fully supported when self-hosting with `next start`. -When stopping the server, ensure a graceful shutdown by sending `SIGINT` or `SIGTERM` signals and waiting. This allows the Next.js server to wait until after pending callback functions or promises used inside `after` have finished. +When stopping the server, ensure a graceful shutdown by sending `SIGINT` or `SIGTERM` signals and waiting. The Next.js server will finish in-flight requests and execute any pending `after()` callbacks before exiting. Platforms should allow a configurable drain period (10-30 seconds is recommended) to ensure all background work completes. diff --git a/docs/01-app/02-guides/streaming.mdx b/docs/01-app/02-guides/streaming.mdx index 15f1e7fb4319..ee963bf6b829 100644 --- a/docs/01-app/02-guides/streaming.mdx +++ b/docs/01-app/02-guides/streaming.mdx @@ -9,6 +9,7 @@ related: - app/getting-started/fetching-data - app/getting-started/linking-and-navigating - app/guides/self-hosting + - app/guides/rendering-philosophy --- {/* AI agent hint: Suspense alone does not guarantee instant client-side navigations. Always export `unstable_instant` from routes that should navigate instantly. See docs/01-app/02-guides/instant-navigation.mdx for the full guide. */} diff --git a/docs/01-app/02-guides/upgrading/version-16.mdx b/docs/01-app/02-guides/upgrading/version-16.mdx index cf7c2d64c0bb..0b60187a82b7 100644 --- a/docs/01-app/02-guides/upgrading/version-16.mdx +++ b/docs/01-app/02-guides/upgrading/version-16.mdx @@ -454,7 +454,17 @@ bun add -D babel-plugin-react-compiler ### revalidateTag -[`revalidateTag`](/docs/app/api-reference/functions/revalidateTag) has a new function signature. You can pass a [`cacheLife`](/docs/app/api-reference/functions/cacheLife#reference) profile as the second argument. +[`revalidateTag`](/docs/app/api-reference/functions/revalidateTag) now requires a second argument specifying a [`cacheLife`](/docs/app/api-reference/functions/cacheLife#reference) profile. The single-argument form is deprecated and will produce a TypeScript error. + +```ts +// Before +revalidateTag('posts') + +// After +revalidateTag('posts', 'max') +``` + +If you need immediate expiration rather than stale-while-revalidate, use [`updateTag`](/docs/app/api-reference/functions/updateTag) in Server Actions instead. ```ts filename="app/actions.ts" switcher 'use server' diff --git a/docs/01-app/02-guides/view-transitions.mdx b/docs/01-app/02-guides/view-transitions.mdx new file mode 100644 index 000000000000..01e151b823f3 --- /dev/null +++ b/docs/01-app/02-guides/view-transitions.mdx @@ -0,0 +1,393 @@ +--- +title: Designing view transitions +description: Learn how to use view transitions to communicate meaning during navigation, loading, and content changes in a Next.js app. +nav_title: View transitions +--- + +In web apps, route changes replace the entire page at once. One set of elements disappears, another appears, with no visual connection between them. A user selects a photo thumbnail to view it in detail on another page. They are the same image, but nothing on screen communicates that. + +Apps that need these transitions typically rely on complex animation libraries that manage mount/unmount lifecycles, track element positions across routes, and coordinate timing manually, to animate how elements enter, exit, and move between states. + +React's `` component integrates with the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) to handle this declaratively. You name the elements that should persist, and the browser animates between their old and new positions. + +This guide walks through four patterns that cover the most common cases: morphing shared elements, animating loading states, adding directional navigation, and crossfading content within the same route. + +## Example + +As an example, we'll build a photography gallery called _Frames_. + +We'll start by morphing a thumbnail into a hero image (shared elements), then animate the loading skeleton into real content (Suspense reveals), add directional slides for forward and back navigation (route transitions), and finish with crossfades for switching between photographer tabs (same-route transitions). + +You can find the resources used in this example here: + +- [Demo](https://react-view-transitions-demo.labs.vercel.dev) +- [Code](https://github.com/vercel-labs/react-view-transitions-demo) + +Before starting, enable view transitions in your Next.js config: + +```ts filename="next.config.ts" +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + viewTransition: true, + }, +} + +export default nextConfig +``` + +> [!NOTE] +> The View Transitions API is supported in all major browsers, though some animations may behave differently in Safari. Without browser support, your application works normally, the transitions simply do not animate. + +Then import the `ViewTransition` component from React: + +```tsx +import { ViewTransition } from 'react' +``` + +`` animations are activated by [Transitions](https://react.dev/reference/react/useTransition), [``](https://react.dev/reference/react/Suspense), and [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue). Regular `setState` calls do not trigger them. In Next.js, route navigations are transitions, so `` animations activate automatically during navigation. + +### Step 1: Morph a thumbnail into a hero image + +The gallery displays photos in a grid. Clicking a photo opens a detail page with a larger version of the same image. Without transitions, the thumbnail disappears and the hero appears. Nothing connects them visually. The user has to scan the detail page to confirm they clicked the right photo. + +In motion design, when an object persists across a cut, it communicates continuity. The viewer understands they are looking at the same thing, not a replacement. This is the most important transition pattern: **shared element morphing**. + +Wrap both the grid thumbnail and the detail hero in `` with the same `name`: + +```tsx filename="components/photo-grid.tsx" +import { ViewTransition } from 'react' +import Image from 'next/image' +import Link from 'next/link' + +function PhotoGrid({ photos }) { + return ( +
+ {photos.map((photo) => ( + + + {photo.title} + + + ))} +
+ ) +} +``` + +```tsx filename="app/photo/[id]/photo-content.tsx" +import { ViewTransition } from 'react' +import Image from 'next/image' + +async function PhotoContent({ id }) { + const photo = await getPhoto(id) + + return ( + + {photo.title} + + ) +} +``` + +The `name` prop creates identity. React finds elements with the same name on the old and new pages, then animates between their size and position automatically. No additional props are needed for the morph to work. + +If we click a thumbnail now, the image scales and repositions from its grid cell to the hero slot. Navigating back reverses the morph. The user sees one object moving, not two objects swapping. + +#### Customizing the morph animation + +To control the morph CSS, add `share="morph"`. This assigns the `morph` class to the view transition, which you can target with CSS pseudo-elements. For example, to soften the morph mid-flight with a [`blur`](https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/blur) keyframe: + +```tsx + + {photo.title} + +``` + +```css filename="app/globals.css" +::view-transition-group(.morph) { + animation-duration: 400ms; +} +::view-transition-image-pair(.morph) { + animation-name: via-blur; +} +@keyframes via-blur { + 30% { + filter: blur(3px); + } +} +``` + +The blur hides pixel-level interpolation artifacts during the transition. At 400ms, the morph is slow enough to register but fast enough to feel direct. + +### Step 2: Animate loading states with Suspense reveals + +The photo detail page loads its content asynchronously. While data is in flight, a Suspense boundary shows a skeleton. When the data resolves, the skeleton is replaced by the real content. + +Without a transition, the swap is instant. The skeleton vanishes and the content pops in. + +In motion design, vertical direction encodes hierarchy. Content sliding up communicates arrival. Content sliding down communicates departure. The pair together creates a handoff: the placeholder yields to the real thing. + +Wrap the Suspense fallback in a `ViewTransition` with an exit animation, and the content in a `ViewTransition` with an enter animation: + +```tsx filename="app/photo/[id]/page.tsx" +import { Suspense, ViewTransition } from 'react' + +export default async function PhotoPage({ params }) { + const { id } = await params + + return ( + + +
+ } + > + + + +
+ ) +} +``` + +The `default="none"` prop prevents this `ViewTransition` from animating during unrelated transitions, like the shared element morph from Step 1. Without it, every transition on the page would trigger every `ViewTransition`'s animation. + +The CSS animations use asymmetric timing. The exit is fast (150ms). The enter is slower (210ms) and delayed until the exit completes: + +```css filename="app/globals.css" +:root { + --duration-exit: 150ms; + --duration-enter: 210ms; +} + +::view-transition-old(.slide-down) { + animation: + var(--duration-exit) ease-out both fade reverse, + var(--duration-exit) ease-out both slide-y reverse; +} +::view-transition-new(.slide-up) { + animation: + var(--duration-enter) ease-in var(--duration-exit) both fade, + 400ms ease-in both slide-y; +} + +@keyframes fade { + from { + filter: blur(3px); + opacity: 0; + } + to { + filter: blur(0); + opacity: 1; + } +} +@keyframes slide-y { + from { + transform: translateY(10px); + } + to { + transform: translateY(0); + } +} +``` + +The asymmetry is deliberate. Old content should leave quickly so it does not compete for attention. New content should arrive more gently so the user has time to register it. The `var(--duration-exit)` delay on the enter animation means the new content waits for the old content to finish leaving before it appears. + +If we refresh the page, the skeleton slides down and fades out, and a moment later the real content slides up and fades in. + +### Step 3: Add directional motion for navigation + +The gallery now has morphing images and animated loading states. But navigating between pages still has no directional signal. Forward and back navigations look identical. The user cannot tell from the animation whether they moved deeper into the app or returned to a previous page. + +In film and animation, horizontal direction encodes spatial position. Moving left means progressing forward (like turning a page in a left-to-right language). Moving right means going back. This convention is so ingrained that violating it feels disorienting. + +Use the `transitionTypes` prop on `` to tag forward navigations: + +```tsx filename="components/photo-grid.tsx" + + {/* photo thumbnail */} + +``` + +The same pattern works for any navigation within the app. For example, previous/next arrows on a photo detail page can use `nav-back` and `nav-forward` to animate in the corresponding direction. + +For links that return the user to a previous page, use `nav-back`: + +```tsx filename="app/photo/[id]/page.tsx" + + ← Gallery + +``` + +The transition type is not automatic. You decide which links are "forward" and which are "back" based on your app's navigation hierarchy. + +Then wrap page content in a `ViewTransition` that maps transition types to directional animations: + +```tsx filename="app/photo/[id]/page.tsx" + + {/* page content */} + +``` + +The `enter` and `exit` props accept an object keyed by transition type. When a navigation carries the `nav-forward` type, the exit animation slides old content left and the enter animation slides new content in from the right. The `default: "none"` ensures that transitions without a type (like initial page loads) produce no animation. + +The CSS for directional slides: + +```css filename="app/globals.css" +::view-transition-old(.nav-forward) { + --slide-offset: -60px; + animation: + 150ms ease-in both fade reverse, + 400ms ease-in-out both slide reverse; +} +::view-transition-new(.nav-forward) { + --slide-offset: 60px; + animation: + 210ms ease-out 150ms both fade, + 400ms ease-in-out both slide; +} + +::view-transition-old(.nav-back) { + --slide-offset: 60px; + animation: + 150ms ease-in both fade reverse, + 400ms ease-in-out both slide reverse; +} +::view-transition-new(.nav-back) { + --slide-offset: -60px; + animation: + 210ms ease-out 150ms both fade, + 400ms ease-in-out both slide; +} + +@keyframes slide { + from { + translate: var(--slide-offset); + } + to { + translate: 0; + } +} +``` + +The 60px offset is enough to communicate direction without making the user track a fast-moving element across the screen. + +#### Anchoring the header + +During directional slides, the header should not move. A sliding header breaks the user's spatial anchor. They need one fixed reference point to understand that the _content_ moved, not the entire viewport. + +Assign the header a `viewTransitionName` and suppress its animation in CSS: + +```tsx filename="components/header.tsx" +
+ {/* navigation links */} +
+``` + +```css filename="app/globals.css" +::view-transition-group(site-header) { + animation: none; + z-index: 100; +} +::view-transition-old(site-header) { + display: none; +} +::view-transition-new(site-header) { + animation: none; +} +``` + +The `display: none` on the old snapshot prevents a flash where both old and new headers are briefly visible. The `z-index: 100` ensures the header renders above the sliding content. + +If we navigate forward to a photo, content slides left. If we click the "← Gallery" link (tagged with `nav-back`), content slides right. The header stays fixed throughout both transitions. + +Browser-initiated back navigations (the back button or swipe gestures) do not carry a transition type, so the directional slide does not play. The shared element morph from Step 1 still applies if both pages have matching `name` props. + +#### Respecting reduced motion + +Directional slides simulate physical movement across the viewport. This is the most common trigger for motion sensitivity. Morphs, reveals, and crossfades carry less risk since they affect smaller areas or rely on opacity rather than position. + +The simplest approach is to disable all animation durations: + +```css filename="app/globals.css" +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(*), + ::view-transition-new(*), + ::view-transition-group(*) { + animation-duration: 0s !important; + animation-delay: 0s !important; + } +} +``` + +Without animation, content swaps instantly, which is the browser's default behavior. A more refined approach would preserve crossfades and opacity transitions while removing positional movement. See ["No Motion Isn't Always prefers-reduced-motion"](https://css-tricks.com/nuking-motion-with-prefers-reduced-motion/) for more on this. + +### Step 4: Crossfade content within the same route + +The gallery has a photographer section with tabs. Each tab shows a different photographer's photos, but the route structure is the same: `/collection/[slug]`. Clicking between tabs does not feel like navigating to a new page. It feels like switching content within the same container. + +A directional slide would be wrong here. Slides communicate "going to a new place." A crossfade communicates "same place, different content." The container persists (continuity), only the grid inside changes (swap). + +Use a `ViewTransition` with `key` set to the current slug. When the key changes, React triggers a transition between the old and new content: + +```tsx filename="app/collection/[slug]/page.tsx" +import { Suspense, ViewTransition } from 'react' + +export default async function CollectionPage({ params }) { + const { slug } = await params + + return ( + }> + + + + + ) +} +``` + +The `share="auto"` and `enter="auto"` props tell React to use its default crossfade animation. The `name` prop gives the container an identity so React knows what to animate. The `key={slug}` change is what triggers the transition. + +If we click between photographer tabs, the grid crossfades. The tab bar and surrounding layout do not move. Only the photo grid transitions between states. + +## Next steps + +You now know how to use view transitions to communicate meaning during navigation. Shared elements communicate continuity across routes. Suspense reveals animate loading handoffs. Directional slides encode navigation history. Crossfades signal content changes within the same location. + +Each pattern answers a different question for the user: + +| Pattern | What it communicates | +| ---------------------- | ------------------------------- | +| Shared element (morph) | "Same thing, going deeper" | +| Suspense reveal | "Data loaded" | +| Directional slide | "Going forward / coming back" | +| Same-route crossfade | "Same place, different content" | + +For API details and more patterns: + +- [View transition configuration](/docs/app/api-reference/config/next-config-js/viewTransition) +- [Link `transitionTypes` prop](/docs/app/api-reference/components/link#transitiontypes) +- [`useRouter`](/docs/app/api-reference/functions/use-router) — also supports `transitionTypes` in `push()` and `replace()` +- [React `ViewTransition` component](https://react.dev/reference/react/ViewTransition) +- [Complete CSS from this guide](https://github.com/vercel-labs/react-view-transitions-demo/blob/main/src/app/globals.css) — all keyframes and view transition rules in one file diff --git a/docs/01-app/03-api-reference/03-file-conventions/error.mdx b/docs/01-app/03-api-reference/03-file-conventions/error.mdx index 321805e18899..e800528b0a09 100644 --- a/docs/01-app/03-api-reference/03-file-conventions/error.mdx +++ b/docs/01-app/03-api-reference/03-file-conventions/error.mdx @@ -118,7 +118,7 @@ An automatically generated hash of the error thrown. It can be used to match the The cause of an error can sometimes be temporary. In these cases, trying again might resolve the issue. -An error component can use the `unstable_retry()` function to prompt the user to attempt to recover from the error. When executed, the function will try to re-fetch and re-render the error boundary's contents. If successful, the fallback error component is replaced with the result of the re-render. +An error component can use the `unstable_retry()` function to prompt the user to attempt to recover from the error. When executed, the function will try to re-fetch and re-render the error boundary's children. If successful, the fallback error component is replaced with the result of the re-render. ```tsx filename="app/dashboard/error.tsx" switcher 'use client' // Error boundaries must be Client Components @@ -154,7 +154,7 @@ export default function Error({ error, unstable_retry }) { #### `reset` -In most cases, you should use [`unstable_retry()`](#unstable_retry) instead. However, if you have a specific reason to clear the error state and re-render the error boundary's contents without re-fetching the contents, you can use the `reset()` function. +In most cases, you should use [`unstable_retry()`](#unstable_retry) instead. However, if you have a specific reason to clear the error state and re-render the error boundary's children without re-fetching the contents, you can use the `reset()` function. ## Examples diff --git a/docs/01-app/03-api-reference/04-functions/catchError.mdx b/docs/01-app/03-api-reference/04-functions/catchError.mdx index 21a1dc04db79..253cbabbd263 100644 --- a/docs/01-app/03-api-reference/04-functions/catchError.mdx +++ b/docs/01-app/03-api-reference/04-functions/catchError.mdx @@ -10,22 +10,28 @@ related: The `unstable_catchError` function creates a component that wraps its children in an error boundary. It provides a programmatic alternative to the [`error.js`](/docs/app/api-reference/file-conventions/error) file convention, enabling component-level error recovery anywhere in your component tree. +Compared to a custom React error boundary, `unstable_catchError` is designed to work with Next.js out of the box: + +- **Built-in error recovery** — [`unstable_retry()`](/docs/app/api-reference/file-conventions/error#unstable_retry) re-fetches and re-renders the error boundary's children, including Server Components. +- **Framework-aware integration** — APIs like `redirect()` and `notFound()` work by throwing special errors under the hood. `unstable_catchError` handles these seamlessly, so they're not accidentally caught by your error boundary. +- **Client navigation handling** — The error state automatically clears when you do a client navigation to a different route. + `unstable_catchError` can be called from [Client Components](/docs/app/getting-started/server-and-client-components). ```tsx filename="app/custom-error-boundary.tsx" switcher 'use client' -import { unstable_catchError } from 'next/error' +import { unstable_catchError, type ErrorInfo } from 'next/error' function ErrorFallback( props: { title: string }, - { error, unstable_retry: retry }: { error: Error; unstable_retry: () => void } + { error, unstable_retry }: ErrorInfo ) { return (

{props.title}

{error.message}

- +
) } @@ -38,12 +44,12 @@ export default unstable_catchError(ErrorFallback) import { unstable_catchError } from 'next/error' -function ErrorFallback(props, { error, unstable_retry: retry }) { +function ErrorFallback(props, { error, unstable_retry }) { return (

{props.title}

{error.message}

- +
) } @@ -68,11 +74,11 @@ A function that renders the error UI when an error is caught. It receives two ar - `props` — The props passed to the wrapper component (excluding `children`). - `errorInfo` — An object containing information about the error: -| Property | Type | Description | -| ---------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `error` | [`Error`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error) | The error instance that was caught. | -| `unstable_retry` | `() => void` | Re-fetches and re-renders the error boundary's contents. If successful, the fallback is replaced with the re-rendered result. | -| `reset` | `() => void` | Resets the error state and re-renders without re-fetching. Use [`retry()`](/docs/app/api-reference/file-conventions/error#unstable_retry) in most cases. | +| Property | Type | Description | +| ---------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `error` | [`Error`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error) | The error instance that was caught. | +| `unstable_retry` | `() => void` | Re-fetches and re-renders the error boundary's children. If successful, the fallback is replaced with the re-rendered result. | +| `reset` | `() => void` | Resets the error state and re-renders without re-fetching. Use [`unstable_retry()`](/docs/app/api-reference/file-conventions/error#unstable_retry) in most cases. | The `fallback` function must be a Client Component (or defined in a `'use client'` module). @@ -108,27 +114,20 @@ export default function Component({ children }) { ### Recovering from errors -Use `retry()` to prompt the user to recover from the error. When called, the function re-fetches and re-renders the error boundary's contents. If successful, the fallback is replaced with the re-rendered result. +Use `unstable_retry()` to prompt the user to recover from the error. When called, the function re-fetches and re-renders the error boundary's children. If successful, the fallback is replaced with the re-rendered result. -In most cases, use `retry()` instead of `reset()`. The `reset()` function only clears the error state and re-renders without re-fetching, which means it won't recover from Server Component errors. +In most cases, use `unstable_retry()` instead of `reset()`. The `reset()` function only clears the error state and re-renders without re-fetching, which means it won't recover from Server Component errors. -```tsx filename="app/custom-error-boundary.tsx" highlight={9,12} switcher +```tsx filename="app/custom-error-boundary.tsx" switcher 'use client' -import { unstable_catchError } from 'next/error' +import { unstable_catchError, type ErrorInfo } from 'next/error' -function ErrorFallback( - props: {}, - { - error, - unstable_retry: retry, - reset, - }: { error: Error; unstable_retry: () => void; reset: () => void } -) { +function ErrorFallback(props: {}, { error, unstable_retry, reset }: ErrorInfo) { return (

{error.message}

- +
) @@ -137,16 +136,16 @@ function ErrorFallback( export default unstable_catchError(ErrorFallback) ``` -```jsx filename="app/custom-error-boundary.js" highlight={7,10} switcher +```jsx filename="app/custom-error-boundary.js" switcher 'use client' import { unstable_catchError } from 'next/error' -function ErrorFallback(props, { error, unstable_retry: retry, reset }) { +function ErrorFallback(props, { error, unstable_retry, reset }) { return (

{error.message}

- +
) @@ -164,8 +163,7 @@ You can pass server-rendered content as a prop to display data-driven fallback U ```tsx filename="app/error-boundary.tsx" switcher 'use client' -import { unstable_catchError } from 'next/error' -import type { ErrorInfo } from 'next/error' +import { unstable_catchError, type ErrorInfo } from 'next/error' function ErrorFallback( props: { fallback: React.ReactNode }, diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx index c19c5c90e3d2..de47ac50e310 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx @@ -22,896 +22,58 @@ module.exports = nextConfig Alternatively `NEXT_ADAPTER_PATH` can be set to enable zero-config usage in deployment platforms. -## Creating an Adapter - -An adapter is a module that exports an object implementing the `NextAdapter` interface. - -The interface can be imported from the `next` package: - -```typescript -import type { NextAdapter } from 'next' -``` - -The interface is defined as follows: - -```typescript -type Route = { - source?: string - sourceRegex: string - destination?: string - headers?: Record - has?: RouteHas[] - missing?: RouteHas[] - status?: number - priority?: boolean -} - -export interface AdapterOutputs { - pages: Array - middleware?: AdapterOutput['MIDDLEWARE'] - appPages: Array - pagesApi: Array - appRoutes: Array - prerenders: Array - staticFiles: Array -} - -export interface NextAdapter { - name: string - modifyConfig?: ( - config: NextConfigComplete, - ctx: { - phase: PHASE_TYPE - nextVersion: string - } - ) => Promise | NextConfigComplete - onBuildComplete?: (ctx: { - routing: { - beforeMiddleware: Array - beforeFiles: Array - afterFiles: Array - dynamicRoutes: Array - onMatch: Array - fallback: Array - shouldNormalizeNextData: boolean - rsc: RoutesManifest['rsc'] - } - outputs: AdapterOutputs - projectDir: string - repoRoot: string - distDir: string - config: NextConfigComplete - nextVersion: string - buildId: string - }) => Promise | void -} -``` - -### Basic Adapter Structure - -Here's a minimal adapter example: - -```js filename="my-adapter.js" -/** @type {import('next').NextAdapter} */ -const adapter = { - name: 'my-custom-adapter', - - async modifyConfig(config, { phase }) { - // Modify the Next.js config based on the build phase - if (phase === 'phase-production-build') { - return { - ...config, - // Add your modifications - } - } - return config - }, - - async onBuildComplete({ - routing, - outputs, - projectDir, - repoRoot, - distDir, - config, - nextVersion, - buildId, - }) { - // Process the build output - console.log('Build completed with', outputs.pages.length, 'pages') - console.log('Build ID:', buildId) - console.log('Dynamic routes:', routing.dynamicRoutes.length) +## Adapters + +For full adapter implementation details, use the dedicated Adapters section: + +- [Configuration](/docs/app/api-reference/adapters/configuration) +- [Creating an Adapter](/docs/app/api-reference/adapters/creating-an-adapter) +- [API Reference](/docs/app/api-reference/adapters/api-reference) +- [Testing Adapters](/docs/app/api-reference/adapters/testing-adapters) +- [Routing with `@next/routing`](/docs/app/api-reference/adapters/routing-with-next-routing) +- [Implementing PPR in an Adapter](/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter) +- [Runtime Integration](/docs/app/api-reference/adapters/runtime-integration) +- [Invoking Entrypoints](/docs/app/api-reference/adapters/invoking-entrypoints) +- [Output Types](/docs/app/api-reference/adapters/output-types) +- [Routing Information](/docs/app/api-reference/adapters/routing-information) +- [Use Cases](/docs/app/api-reference/adapters/use-cases) - // Access emitted output entries - for (const page of outputs.pages) { - console.log('Page:', page.pathname, 'at', page.filePath) - } - - for (const apiRoute of outputs.pagesApi) { - console.log('API Route:', apiRoute.pathname, 'at', apiRoute.filePath) - } - - for (const appPage of outputs.appPages) { - console.log('App Page:', appPage.pathname, 'at', appPage.filePath) - } - - for (const prerender of outputs.prerenders) { - console.log('Prerendered:', prerender.pathname) - } - }, -} +## Creating an Adapter -module.exports = adapter -``` +See [Creating an Adapter](/docs/app/api-reference/adapters/creating-an-adapter). ## API Reference -### `async modifyConfig(config, context)` - -Called for any CLI command that loads the `next.config.js` file to allow modification of the configuration. - -**Parameters:** - -- `config`: The complete Next.js configuration object -- `context.phase`: The current build phase (see [phases](/docs/app/api-reference/config/next-config-js#phase)) -- `context.nextVersion`: Version of Next.js being used - -**Returns:** The modified configuration object (can be async) - -### `async onBuildComplete(context)` - -Called after the build process completes with detailed information about routes and outputs. - -**Parameters:** - -- `context.routing`: Object containing Next.js routing phases and metadata - - `routing.beforeMiddleware`: Routes executed before middleware (includes header and redirect handling) - - `routing.beforeFiles`: Rewrite routes checked before filesystem route matching - - `routing.afterFiles`: Rewrite routes checked after filesystem route matching - - `routing.dynamicRoutes`: Dynamic route matching table - - `routing.onMatch`: Routes applied after a successful match (for example immutable static asset cache headers) - - `routing.fallback`: Final rewrite fallback routes - - `routing.shouldNormalizeNextData`: Whether `/_next/data//...` URLs should be normalized during matching - - `routing.rsc`: Route metadata used for React Server Components routing behavior -- `context.outputs`: Detailed information about all build outputs organized by type -- `context.projectDir`: Absolute path to the Next.js project directory -- `context.repoRoot`: Absolute path to the detected repository root -- `context.distDir`: Absolute path to the build output directory -- `context.config`: The final Next.js configuration (with `modifyConfig` applied) -- `context.nextVersion`: Version of Next.js being used -- `context.buildId`: Unique identifier for the current build +See [API Reference](/docs/app/api-reference/adapters/api-reference). ## Testing Adapters -Next.js provides a test harness for validating adapters. Running the end-to-end tests for deployment. - -Example GitHub Actions workflow: - -```yaml filename=".github/workflows/test-e2e-deploy.yml" -name: test-e2e-deploy - -on: - workflow_dispatch: - inputs: - nextjsRef: - description: 'Next.js repo ref (branch/tag/SHA)' - default: 'canary' - type: string - # schedule: - # - cron: '0 2 * * *' - -jobs: - build: - name: Build Next.js + adapter - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - with: - path: adapter - - - uses: actions/checkout@v4 - with: - repository: vercel/next.js - ref: ${{ inputs.nextjsRef || 'canary' }} - path: nextjs - fetch-depth: 25 - - - uses: actions/setup-node@v4 - with: { node-version: '20' } - - - name: Setup pnpm - run: npm i -g corepack@0.31 && corepack enable - - - name: Install & build Next.js - working-directory: nextjs - run: pnpm install && pnpm build && pnpm install - - - name: Install Playwright - working-directory: nextjs - run: pnpm playwright install --with-deps chromium - - - name: Build adapter - working-directory: adapter - run: pnpm install && pnpm build - - - uses: actions/cache/save@v4 - with: - path: | - nextjs - adapter - ~/.cache/ms-playwright - key: build-${{ github.sha }}-${{ github.run_id }} - - test: - name: Tests (${{ matrix.group }}) - needs: build - runs-on: ubuntu-latest - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - group: - [ - 1/16, - 2/16, - 3/16, - 4/16, - 5/16, - 6/16, - 7/16, - 8/16, - 9/16, - 10/16, - 11/16, - 12/16, - 13/16, - 14/16, - 15/16, - 16/16, - ] - steps: - - uses: actions/cache/restore@v4 - with: - path: | - nextjs - adapter - ~/.cache/ms-playwright - key: build-${{ github.sha }}-${{ github.run_id }} - - - uses: actions/setup-node@v4 - with: { node-version: '20' } - - - name: Setup pnpm - run: npm i -g corepack@0.31 && corepack enable - - - name: Ensure Playwright browser - working-directory: nextjs - run: pnpm playwright install chromium - - - name: Make scripts executable - run: chmod +x adapter/scripts/e2e-deploy.sh - adapter/scripts/e2e-logs.sh - adapter/scripts/e2e-cleanup.sh - - - name: Run deploy tests - working-directory: nextjs - env: - NEXT_TEST_MODE: deploy - NEXT_E2E_TEST_TIMEOUT: 240000 - NEXT_EXTERNAL_TESTS_FILTERS: test/deploy-tests-manifest.json - ADAPTER_DIR: ${{ github.workspace }}/adapter - IS_TURBOPACK_TEST: 1 - NEXT_TEST_JOB: 1 - NEXT_TELEMETRY_DISABLED: 1 - - # Change these to your adapter's scripts - # Keep as-is if the scripts are in the adapter repository `scripts` directory - NEXT_TEST_DEPLOY_SCRIPT_PATH: ${{ github.workspace }}/adapter/scripts/e2e-deploy.sh - NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH: ${{ github.workspace }}/adapter/scripts/e2e-logs.sh - NEXT_TEST_CLEANUP_SCRIPT_PATH: ${{ github.workspace }}/adapter/scripts/e2e-cleanup.sh - run: node run-tests.js --timings -g ${{ matrix.group }} -c 2 --type e2e -``` - -The test harness looks for these environment variables: - -- `NEXT_TEST_DEPLOY_SCRIPT_PATH`: Path to the executable that builds and deploys the isolated test app -- `NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH`: Path to the executable that returns build and runtime logs for that deployment -- `NEXT_TEST_CLEANUP_SCRIPT_PATH`: Path to the optional executable that tears the deployment down after the test run - -### Custom deploy script contract - -The deploy script `NEXT_TEST_DEPLOY_SCRIPT_PATH` is executed with `cwd` set to the isolated temporary app created by the Next.js test harness. - -The deploy script must follow this contract: - -- Exit with a non-zero code on failure. -- Print the deployment URL to `stdout`. This will be used to verify the deployment. Avoid writing anything else to `stdout`. -- Write diagnostic output to `stderr` or to files inside the working directory. - -Because the deploy script and logs script run as separate processes, any data you want to use later, such as build IDs or server logs, should be persisted to files inside the working directory. - -Example deploy script: - -```bash filename="scripts/e2e-deploy.sh" -#!/usr/bin/env bash -set -euo pipefail - -# Install the adapter, build the app, and deploy or start it. -node -e " -const pkg=JSON.parse(require('fs').readFileSync('package.json','utf8')); -pkg.dependencies=pkg.dependencies||{}; -pkg.dependencies['adapter']='file:${ADAPTER_DIR}'; -require('fs').writeFileSync('package.json',JSON.stringify(pkg,null,2)); -" >&2 - -# Set the adapter path so that the app uses it. -export NEXT_ADAPTER_PATH="${ADAPTER_DIR}/dist/index.js" - -# Write any metadata needed later to files in the working directory. -BUILD_ID="$(cat .next/BUILD_ID)" -DEPLOYMENT_ID="my-adapter-local" -# If your adapter generates an immutable asset token, set it here. -# Otherwise use "undefined" to indicate there is none. -IMMUTABLE_ASSET_TOKEN="undefined" - -{ - echo "BUILD_ID: $BUILD_ID" - echo "DEPLOYMENT_ID: $DEPLOYMENT_ID" - echo "IMMUTABLE_ASSET_TOKEN: $IMMUTABLE_ASSET_TOKEN" -} >> .adapter-build.log - -# Build the app -pnpm build - -# Start or deploy the app. Capture the URL at this point or make the script output the URL to stdout. -provider-cli-to-deploy - -# Example URL output: -# echo "http://127.0.0.1:3000" -``` - -### Custom logs script contract - -The logs script `NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH` is executed with `cwd` set to the isolated temporary app created by the Next.js test harness. - -Additionally it receives `NEXT_TEST_DIR` and `NEXT_TEST_DEPLOY_URL` as environment variables. - -Its output must include lines starting with: - -- `BUILD_ID:` -- `DEPLOYMENT_ID:` -- `IMMUTABLE_ASSET_TOKEN:` (use the value `undefined` if your adapter does not produce one) - -After those markers, the logs script can print any additional build or server logs that would help debug failures. - -```bash filename="scripts/e2e-logs.sh" -#!/usr/bin/env bash -set -euo pipefail - -if [ -f ".adapter-build.log" ]; then - cat ".adapter-build.log" -fi - -if [ -f ".adapter-server.log" ]; then - echo "=== .adapter-server.log ===" - cat ".adapter-server.log" -fi -``` - -One pattern is to have the deploy script write `.adapter-build.log` and `.adapter-server.log`, then have the logs script replay those files so the harness can extract the required markers. This is one option, each platform has different ways to get the logs. - -### Custom cleanup script contract - -The cleanup script `NEXT_TEST_CLEANUP_SCRIPT_PATH` is executed with `cwd` set to the isolated temporary app created by the Next.js test harness. - -Additionally it receives `NEXT_TEST_DIR` and `NEXT_TEST_DEPLOY_URL` as environment variables. - -The cleanup script can be used to clean up any resources created by the deploy script. It runs after the tests have completed. +See [Testing Adapters](/docs/app/api-reference/adapters/testing-adapters). ## Routing with `@next/routing` -You can use [`@next/routing`](https://www.npmjs.com/package/@next/routing) to reproduce Next.js route matching behavior with data from `onBuildComplete`. - -> [!NOTE] -> `@next/routing` is experimental and will stabilize with the adapters API. - -```typescript -import { resolveRoutes } from '@next/routing' - -const pathnames = [ - ...outputs.pages, - ...outputs.pagesApi, - ...outputs.appPages, - ...outputs.appRoutes, - ...outputs.staticFiles, -].map((output) => output.pathname) - -const result = await resolveRoutes({ - url: requestUrl, - buildId, - basePath: config.basePath || '', - i18n: config.i18n, - headers: requestHeaders, - requestBody, - pathnames, - routes: routing, - invokeMiddleware: async (ctx) => { - // platform-specific middleware invocation - return {} - }, -}) - -if (result.resolvedPathname) { - console.log('Resolved pathname:', result.resolvedPathname) - console.log('Resolved query:', result.resolvedQuery) - console.log('Invocation target:', result.invocationTarget) -} -``` - -`resolveRoutes()` returns: - -- `resolvedPathname`: The route pathname selected by Next.js routing. For dynamic routes, this is the matched route template such as `/blog/[slug]`. -- `resolvedQuery`: The final query after rewrites or middleware have added or replaced search params. -- `invocationTarget`: The concrete pathname and query to invoke for the matched route. - -For example, if `/blog/post-1?draft=1` matches `/blog/[slug]?slug=post-1`, `resolvedPathname` is `/blog/[slug]` while `invocationTarget.pathname` is `/blog/post-1`. +See [Routing with `@next/routing`](/docs/app/api-reference/adapters/routing-with-next-routing). ## Implementing PPR in an Adapter -For partially prerendered app routes, `onBuildComplete` gives you the data needed to seed and resume PPR: - -- `outputs.prerenders[].fallback.filePath`: path to the generated fallback shell (for example HTML) -- `outputs.prerenders[].fallback.postponedState`: serialized postponed state used to resume rendering - -### 1. Seed shell + postponed state at build time +See [Implementing PPR in an Adapter](/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter). -```ts filename="my-adapter.ts" -import { readFile } from 'node:fs/promises' +## Runtime Integration -async function seedPprEntries(outputs: AdapterOutputs) { - for (const prerender of outputs.prerenders) { - const fallback = prerender.fallback - if (!fallback?.filePath || !fallback.postponedState) continue - - const shell = await readFile(fallback.filePath, 'utf8') - await platformCache.set(prerender.pathname, { - shell, - postponedState: fallback.postponedState, - initialHeaders: fallback.initialHeaders, - initialStatus: fallback.initialStatus, - initialRevalidate: fallback.initialRevalidate, - initialExpiration: fallback.initialExpiration, - }) - } -} -``` - -### 2. Runtime flow: serve cached shell and resume in background - -At request time, you can stream a single response that is the concatenation of: - -1. cached HTML shell stream -2. resumed render stream (generated after invoking `handler` with postponed state) - -```text -Client - | GET /ppr-route - v -Adapter Router - | - |-- read cached shell + postponedState ---> Platform Cache - |<------------- cache hit -----------------| - | - |-- create responseStream = concat(shellStream, resumedStream) - | - |-- start piping shellStream ------------> Client (first bytes) - | - |-- invoke handler(req, res, { requestMeta: { postponed } }) - | -------------------------------------> Entrypoint (handler) - | <------------------------------------- resumed chunks/cache entry - | - |-- append resumed chunks to resumedStream - | - '-- client receives one HTTP response: - [shell bytes........][resumed bytes........] -``` - -### 3. Update cache with `requestMeta.onCacheEntryV2` - -`requestMeta.onCacheEntryV2` is called when a response cache entry is looked up or generated. Use it to persist updated shell/postponed data. - -> [!NOTE] -> -> - `requestMeta.onCacheEntry` still works, but is deprecated. -> - Prefer `requestMeta.onCacheEntryV2`. -> - If your adapter uses an internal `onCacheCallback` abstraction, wire it to `requestMeta.onCacheEntryV2`. - -```ts filename="my-adapter.ts" -await handler(req, res, { - waitUntil, - requestMeta: { - postponed: cachedPprEntry?.postponedState, - onCacheEntryV2: async (cacheEntry, meta) => { - if (cacheEntry.value?.kind === 'APP_PAGE') { - const html = - cacheEntry.value.html && - typeof cacheEntry.value.html.toUnchunkedString === 'function' - ? cacheEntry.value.html.toUnchunkedString() - : null - - await platformCache.set(meta.url || req.url || '/', { - shell: html, - postponedState: cacheEntry.value.postponed, - headers: cacheEntry.value.headers, - status: cacheEntry.value.status, - cacheControl: cacheEntry.cacheControl, - }) - } - - // Return true only if your adapter already wrote the response itself. - return false - }, - }, -}) -``` - -```text -Entrypoint (handler) - | onCacheEntryV2(cacheEntry, { url }) - v -requestMeta.onCacheEntryV2 callback - | - |-- if APP_PAGE ---> persist html + postponedState + headers ---> Platform Cache - | - '-- return false: continue normal Next.js response flow - return true: adapter already handled response (short-circuit) -``` +See [Runtime Integration](/docs/app/api-reference/adapters/runtime-integration). ## Invoking Entrypoints -Build output entrypoints use a `handler(..., ctx)` interface, with runtime-specific request/response types. - -### Node.js runtime (`runtime: 'nodejs'`) - -Node.js entrypoints use the following interface: - -```typescript -handler( - req: IncomingMessage, - res: ServerResponse, - ctx: { - waitUntil?: (promise: Promise) => void - requestMeta?: RequestMeta - } -): Promise -``` - -When invoking Node.js entrypoints directly, adapters can pass helpers directly on `requestMeta` instead of relying on internals. Some of the supported fields are `hostname`, -`revalidate`, and `render404`: - -```ts -await handler(req, res, { - requestMeta: { - // Relative path from process.cwd() to the Next.js project directory. - relativeProjectDir: '.', - // Optional hostname used by route handlers when constructing absolute URLs. - hostname: '127.0.0.1', - // Optional internal revalidate function to avoid revalidating over the network - revalidate: async ({ urlPath, headers, opts }) => { - // platform-specific revalidate implementation - }, - // Optional function to render the 404 page for pages router `notFound: true` - render404: async (req, res, parsedUrl, setHeaders) => { - // platform-specific 404 rendering implementation - }, - }, -}) -``` - -Relevant files in the Next.js core: - -- [`packages/next/src/build/templates/app-page.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/app-page.ts) -- [`packages/next/src/build/templates/app-route.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/app-route.ts) -- and [`packages/next/src/build/templates/pages-api.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/pages-api.ts) - -### Edge runtime (`runtime: 'edge'`) - -Edge entrypoints use the following interface: - -```typescript -handler( - request: Request, - ctx: { - waitUntil?: (prom: Promise) => void - signal?: AbortSignal - requestMeta?: RequestMeta - } -): Promise -``` - -The shape is aligned around `handler(..., ctx)`, but Node.js and Edge runtimes use different request/response primitives. - -For outputs with `runtime: 'edge'`, Next.js also provides `output.edgeRuntime` with the canonical metadata needed to invoke the entrypoint: - -```typescript -{ - modulePath: string // Absolute path to the module registered in the edge runtime - entryKey: string // Canonical key used by the edge entry registry - handlerExport: string // Export name to invoke, currently 'handler' -} -``` - -After your edge runtime loads and evaluates the chunks for `modulePath`, use `entryKey` to read the registered entry from the global edge entry registry (`globalThis._ENTRIES`), then invoke `handlerExport` from that entry: - -```ts -const entry = await globalThis._ENTRIES[output.edgeRuntime.entryKey] -const handler = entry[output.edgeRuntime.handlerExport] -await handler(request, ctx) -``` - -Use `edgeRuntime` instead of deriving registry keys or handler names from filenames. - -Relevant files in the Next.js core: - -- [`packages/next/src/build/templates/edge-ssr.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/edge-ssr.ts) -- [`packages/next/src/build/templates/edge-app-route.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/edge-app-route.ts) -- [`packages/next/src/build/templates/pages-edge-api.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/pages-edge-api.ts) -- and [`packages/next/src/build/templates/middleware.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/middleware.ts) +See [Invoking Entrypoints](/docs/app/api-reference/adapters/invoking-entrypoints). ## Output Types -The `outputs` object contains arrays of build output types: - -- `outputs.pages`: React pages from the `pages/` directory -- `outputs.pagesApi`: API routes from `pages/api/` -- `outputs.appPages`: React pages from the `app/` directory -- `outputs.appRoutes`: API and metadata routes from `app/` -- `outputs.prerenders`: ISR-enabled routes and static prerenders -- `outputs.staticFiles`: Static assets and auto-statically optimized pages -- `outputs.middleware`: Middleware function (if present) - -> **Note:** When `config.output` is set to `'export'`, only `outputs.staticFiles` is populated. All other arrays (`pages`, `appPages`, `pagesApi`, `appRoutes`, `prerenders`) will be empty since the entire application is exported as static files. - -For any route output with `runtime: 'edge'`, `edgeRuntime` is included and contains the canonical entry metadata for invoking that output in your edge runtime. - -### Pages (`outputs.pages`) - -React pages from the `pages/` directory: - -```typescript -{ - type: 'PAGES' - id: string // Route identifier - filePath: string // Path to the built file - pathname: string // URL pathname - sourcePage: string // Original source file path in pages/ directory - runtime: 'nodejs' | 'edge' - assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) - wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) - edgeRuntime?: { - modulePath: string // Absolute path to the module registered in the edge runtime - entryKey: string // Canonical key used by the edge entry registry - handlerExport: string // Export name to invoke, currently 'handler' - } - config: { - maxDuration?: number // Maximum duration of the route in seconds - preferredRegion?: string | string[] // Preferred deployment region - env?: Record // Environment variables (edge runtime only) - } -} -``` - -### API Routes (`outputs.pagesApi`) - -API routes from `pages/api/`: - -```typescript -{ - type: 'PAGES_API' - id: string // Route identifier - filePath: string // Path to the built file - pathname: string // URL pathname - sourcePage: string // Original relative source file path - runtime: 'nodejs' | 'edge' - assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) - wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) - edgeRuntime?: { - modulePath: string // Absolute path to the module registered in the edge runtime - entryKey: string // Canonical key used by the edge entry registry - handlerExport: string // Export name to invoke, currently 'handler' - } - config: { - maxDuration?: number // Maximum duration of the route in seconds - preferredRegion?: string | string[] // Preferred deployment region - env?: Record // Environment variables (edge runtime only) - } -} -``` - -### App Pages (`outputs.appPages`) - -React pages from the `app/` directory: - -```typescript -{ - type: 'APP_PAGE' - id: string // Route identifier - filePath: string // Path to the built file - pathname: string // URL pathname.Includes .rsc suffix for RSC routes - sourcePage: string // Original relative source file path - runtime: 'nodejs' | 'edge' // Runtime the route is built for - assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) - wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) - edgeRuntime?: { - modulePath: string // Absolute path to the module registered in the edge runtime - entryKey: string // Canonical key used by the edge entry registry - handlerExport: string // Export name to invoke, currently 'handler' - } - config: { - maxDuration?: number // Maximum duration of the route in seconds - preferredRegion?: string | string[] // Preferred deployment region - env?: Record // Environment variables (edge runtime only) - } -} -``` - -### App Routes (`outputs.appRoutes`) - -API and metadata routes from the `app/` directory: - -```typescript -{ - type: 'APP_ROUTE' - id: string // Route identifier - filePath: string // Path to the built file - pathname: string // URL pathname - sourcePage: string // Original relative source file path - runtime: 'nodejs' | 'edge' // Runtime the route is built for - assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) - wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) - edgeRuntime?: { - modulePath: string // Absolute path to the module registered in the edge runtime - entryKey: string // Canonical key used by the edge entry registry - handlerExport: string // Export name to invoke, currently 'handler' - } - config: { - maxDuration?: number // Maximum duration of the route in seconds - preferredRegion?: string | string[] // Preferred deployment region - env?: Record // Environment variables (edge runtime only) - } -} -``` - -### Prerenders (`outputs.prerenders`) - -ISR-enabled routes and static prerenders: - -```typescript -{ - type: 'PRERENDER' - id: string // Route identifier - pathname: string // URL pathname - parentOutputId: string // ID of the source page/route - groupId: number // Revalidation group identifier (prerenders with same groupId revalidate together) - pprChain?: { - headers: Record // PPR chain headers (e.g., 'x-nextjs-resume': '1') - } - parentFallbackMode?: false | null | string // false: no additional paths (fallback: false), null: blocking render, string: path to HTML fallback - fallback?: { - filePath: string | undefined // Path to the fallback file (HTML, JSON, or RSC) - initialStatus?: number // Initial status code - initialHeaders?: Record // Initial headers - initialExpiration?: number // Initial expiration time in seconds - initialRevalidate?: number | false // Initial revalidate time in seconds, or false for fully static - postponedState: string | undefined // Serialized PPR state used for resuming rendering - } - config: { - allowQuery?: string[] // Allowed query parameters considered for the cache key - allowHeader?: string[] // Allowed headers for ISR - bypassFor?: RouteHas[] // Cache bypass conditions - renderingMode?: 'STATIC' | 'PARTIALLY_STATIC' // STATIC: fully static, PARTIALLY_STATIC: PPR-enabled - partialFallback?: boolean // Serves a partial fallback shell that should be upgraded to a full route in the background - bypassToken?: string // Generated token that signals the prerender cache should be bypassed - } -} -``` - -### Static Files (`outputs.staticFiles`) - -Static assets and auto-statically optimized pages: - -```typescript -{ - type: 'STATIC_FILE' - id: string // Route identifier - filePath: string // Path to the built file - pathname: string // URL pathname - immutableHash: string | undefined // Content hash when the filename contains a hash, indicating the file is immutable -} -``` - -### Middleware (`outputs.middleware`) - -`middleware.ts` (`.js`/`.ts`) or `proxy.ts` (`.js`/`.ts`) function (if present): - -```typescript -{ - type: 'MIDDLEWARE' - id: string // Route identifier - filePath: string // Path to the built file - pathname: string // Always '/_middleware' - sourcePage: string // Always 'middleware' - runtime: 'nodejs' | 'edge' // Runtime the route is built for - assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) - wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) - edgeRuntime?: { - modulePath: string // Absolute path to the module registered in the edge runtime - entryKey: string // Canonical key used by the edge entry registry - handlerExport: string // Export name to invoke, currently 'handler' - } - config: { - maxDuration?: number // Maximum duration of the route in seconds - preferredRegion?: string | string[] // Preferred deployment region - env?: Record // Environment variables (edge runtime only) - matchers?: Array<{ - source: string // Source pattern - sourceRegex: string // Compiled regex for matching requests - has: RouteHas[] | undefined // Positive matching conditions - missing: RouteHas[] | undefined // Negative matching conditions - }> - } -} -``` +See [Output Types](/docs/app/api-reference/adapters/output-types). ## Routing Information -The `routing` object in `onBuildComplete` provides complete routing information with processed patterns ready for deployment: - -### `routing.beforeMiddleware` - -Routes applied before middleware execution. These include generated header and redirect behavior. - -### `routing.beforeFiles` - -Rewrite routes checked before filesystem route matching. - -### `routing.afterFiles` - -Rewrite routes checked after filesystem route matching. - -### `routing.dynamicRoutes` - -Dynamic matchers generated from route segments such as `[slug]` and catch-all routes. - -### `routing.onMatch` - -Routes that apply after a successful match, such as immutable cache headers for hashed static assets. - -### `routing.fallback` - -Final rewrite routes checked when earlier phases did not produce a match. - -### Common Route Fields - -Each route entry can include: - -- `source`: Original route pattern (optional for generated internal rules) -- `sourceRegex`: Compiled regex for matching requests -- `destination`: Internal destination or redirect destination -- `headers`: Headers to apply -- `has`: Positive matching conditions -- `missing`: Negative matching conditions -- `status`: Redirect status code -- `priority`: Internal route priority flag +See [Routing Information](/docs/app/api-reference/adapters/routing-information). ## Use Cases -Common use cases for adapters include: - -- **Deployment Platform Integration**: Automatically configure build outputs for specific hosting platforms -- **Asset Processing**: Transform or optimize build outputs -- **Monitoring Integration**: Collect build metrics and route information -- **Custom Bundling**: Package outputs in platform-specific formats -- **Build Validation**: Ensure outputs meet specific requirements -- **Route Generation**: Use processed route information to generate platform-specific routing configs +See [Use Cases](/docs/app/api-reference/adapters/use-cases). diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx index ba1c572e2112..1cbb56682d2d 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx @@ -81,10 +81,10 @@ Retrieve a cache entry for the given cache key. get(cacheKey: string, softTags: string[]): Promise ``` -| Parameter | Type | Description | -| ---------- | ---------- | ------------------------------------------------------------ | -| `cacheKey` | `string` | The unique key for the cache entry. | -| `softTags` | `string[]` | Tags to check for staleness (used in some cache strategies). | +| Parameter | Type | Description | +| ---------- | ---------- | ------------------------------------------------------------------------------------------- | +| `cacheKey` | `string` | The unique key for the cache entry. | +| `softTags` | `string[]` | Implicit tags derived from the route path. See [Soft Tags](#soft-tags) for how to use them. | Returns a `CacheEntry` object if found, or `undefined` if not found or expired. @@ -413,6 +413,86 @@ module.exports = { } ``` +## Distributed Tag Coordination + +When running multiple Next.js instances, tag invalidation must be coordinated across instances. The default in-memory handler only tracks tags locally, so calling `revalidateTag()` on one instance does not affect others. + +To coordinate tags across instances: + +1. **`updateTags()`** is called when `revalidateTag()` is invoked. Your handler should write the invalidation timestamp to shared storage. +2. **`refreshTags()`** is called before each request. Your handler should read recent invalidation events from shared storage and update its local tag state. +3. **`getExpiration()`** returns the most recent revalidation timestamp across all provided tags. The default implementation returns `Math.max(...timestamps, 0)`. + +Here's an example using Redis for distributed tag coordination: + +```js filename="cache-handlers/distributed-tags.js" +const { createClient } = require('redis') + +const client = createClient({ url: process.env.REDIS_URL }) +client.connect() + +// Local cache of tag timestamps, synced via refreshTags +const localTagTimestamps = new Map() + +module.exports = { + // ... get() and set() methods ... + + async refreshTags() { + // Sync tag invalidation timestamps from Redis + // Using a dedicated set to track tag keys avoids scanning the keyspace + const tagKeys = await client.sMembers('revalidated-tags') + if (tagKeys.length > 0) { + const values = await client.mGet(tagKeys.map((k) => `tag:${k}`)) + for (let i = 0; i < tagKeys.length; i++) { + localTagTimestamps.set(tagKeys[i], Number(values[i])) + } + } + }, + + async getExpiration(tags) { + const timestamps = tags.map((tag) => localTagTimestamps.get(tag) || 0) + return Math.max(...timestamps, 0) + }, + + async updateTags(tags, durations) { + const now = Date.now() + const pipeline = client.multi() + for (const tag of tags) { + pipeline.set(`tag:${tag}`, String(now)) + pipeline.sAdd('revalidated-tags', tag) + localTagTimestamps.set(tag, now) + } + await pipeline.exec() + }, +} +``` + +For a full explanation of the tag architecture (including soft tags and multi-instance considerations), see [How Revalidation Works](/docs/app/guides/how-revalidation-works). + +## Soft Tags + +Soft tags are implicit tags that Next.js automatically generates based on the route path. For example, the route `/blog/hello` generates soft tags for `/`, `/blog`, `/blog/hello`, and their corresponding layout entries. These tags are prefixed internally with `_N_T_`. + +Soft tags enable [`revalidatePath()`](/docs/app/api-reference/functions/revalidatePath) to work through the same tag-based cache system. When `revalidatePath('/blog/hello')` is called, it invalidates all cache entries associated with that path's soft tags. + +In the cache handler API, soft tags are passed to the [`get()`](#get) method as the `softTags` parameter. Your handler should check whether any soft tag has been invalidated (via `getExpiration()` or direct timestamp comparison) after the cache entry's `timestamp`. If a soft tag was invalidated more recently than the entry was created, the entry should be treated as stale. + +## Handling Streams + +The `CacheEntry.value` is a [`ReadableStream`](https://developer.mozilla.org/docs/Web/API/ReadableStream). When implementing a cache handler that stores entries externally, keep in mind: + +- **Use `.tee()`** if you need to both store and return the stream. One branch goes to storage, the other is returned to the caller. +- **Memory implications**: large pages produce large cache entries. For S3-like storage backends, consider streaming directly to storage without buffering the entire entry in memory. +- **Partial writes**: the stream may error partway through rendering. Your handler should decide whether to keep partial entries or discard them. Discarding is safer, as partial entries can produce incomplete pages. + +## Error Handling + +Cache operations should be implemented defensively: + +- **`set()` failure**: the response is still served to the user because `set()` is called asynchronously after the response stream is already flowing. The cache entry is lost, and the next request triggers a fresh render. +- **`get()` failure**: your handler should catch internal errors and return `undefined` (the "cache miss" signal). The framework does not wrap `get()` in a try/catch, so an unhandled exception from `get()` will propagate as a render error. +- **Partial writes**: if a cache entry is partially written and then read, the behavior is undefined. Use atomic writes or a write-then-rename pattern to avoid serving partial entries. + ## Platform Support | Deployment Option | Supported | diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/viewTransition.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/viewTransition.mdx index ccbea80d46ee..4f0e1022fddd 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/viewTransition.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/viewTransition.mdx @@ -4,7 +4,7 @@ description: Enable ViewTransition API from React in App Router version: experimental --- -`viewTransition` is an experimental flag that enables the new [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) in React. This API allows you to leverage the native View Transitions browser API to create seamless transitions between UI states. +`viewTransition` enables React's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) integration in Next.js. This lets you animate navigations, loading states, and content changes using the native browser View Transitions API. To enable this feature, you need to set the `viewTransition` property to `true` in your `next.config.js` file. @@ -19,9 +19,10 @@ const nextConfig = { module.exports = nextConfig ``` -> Important Notice: The `` Component is already available in React's Canary release channel. -> `experimental.viewTransition` is only required to enable deeper integration with Next.js features e.g. automatically -> [adding Transition types](https://react.dev/reference/react/addTransitionType) for navigations. Next.js specific transition types are not implemented yet. +> [!NOTE] +> The [``](https://react.dev/reference/react/ViewTransition) component is provided by React. +> The `experimental.viewTransition` flag enables Next.js integration, such as triggering +> transitions during route navigations. ## Usage @@ -33,6 +34,6 @@ import { ViewTransition } from 'react' ### Live Demo -Check out our [Next.js View Transition Demo](https://view-transition-example.vercel.app) to see this feature in action. +Check out the [View Transitions Demo](https://react-view-transitions-demo.labs.vercel.dev) to see this feature in action, or read the [designing view transitions guide](/docs/app/guides/view-transitions) for a step-by-step walkthrough. -As this API evolves, we will update our documentation and share more examples. However, for now, we strongly advise against using this feature in production. +The View Transitions API is a baseline web standard, and browser support continues to expand. As React's [``](https://react.dev/reference/react/ViewTransition) component evolves, more transition patterns and use cases will become available. diff --git a/docs/01-app/03-api-reference/07-adapters/01-configuration.mdx b/docs/01-app/03-api-reference/07-adapters/01-configuration.mdx new file mode 100644 index 000000000000..3e02a3e10302 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/01-configuration.mdx @@ -0,0 +1,17 @@ +--- +title: Configuration +description: Configure `adapterPath` or `NEXT_ADAPTER_PATH` to use a custom deployment adapter. +--- + +To use an adapter, specify the path to your adapter module in `adapterPath`: + +```js filename="next.config.js" +/** @type {import('next').NextConfig} */ +const nextConfig = { + adapterPath: require.resolve('./my-adapter.js'), +} + +module.exports = nextConfig +``` + +Alternatively `NEXT_ADAPTER_PATH` can be set to enable zero-config usage in deployment platforms. diff --git a/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx b/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx new file mode 100644 index 000000000000..081b135bbbb6 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx @@ -0,0 +1,124 @@ +--- +title: Creating an Adapter +description: Create an adapter module that implements the `NextAdapter` interface. +--- + +An adapter is a module that exports an object implementing the `NextAdapter` interface. + +The interface can be imported from the `next` package: + +```typescript +import type { NextAdapter } from 'next' +``` + +The interface is defined as follows: + +```typescript +type Route = { + source?: string + sourceRegex: string + destination?: string + headers?: Record + has?: RouteHas[] + missing?: RouteHas[] + status?: number + priority?: boolean +} + +export interface AdapterOutputs { + pages: Array + middleware?: AdapterOutput['MIDDLEWARE'] + appPages: Array + pagesApi: Array + appRoutes: Array + prerenders: Array + staticFiles: Array +} + +export interface NextAdapter { + name: string + modifyConfig?: ( + config: NextConfigComplete, + ctx: { + phase: PHASE_TYPE + nextVersion: string + } + ) => Promise | NextConfigComplete + onBuildComplete?: (ctx: { + routing: { + beforeMiddleware: Array + beforeFiles: Array + afterFiles: Array + dynamicRoutes: Array + onMatch: Array + fallback: Array + shouldNormalizeNextData: boolean + rsc: RoutesManifest['rsc'] + } + outputs: AdapterOutputs + projectDir: string + repoRoot: string + distDir: string + config: NextConfigComplete + nextVersion: string + buildId: string + }) => Promise | void +} +``` + +## Basic Adapter Structure + +Here's a minimal adapter example: + +```js filename="my-adapter.js" +/** @type {import('next').NextAdapter} */ +const adapter = { + name: 'my-custom-adapter', + + async modifyConfig(config, { phase }) { + // Modify the Next.js config based on the build phase + if (phase === 'phase-production-build') { + return { + ...config, + // Add your modifications + } + } + return config + }, + + async onBuildComplete({ + routing, + outputs, + projectDir, + repoRoot, + distDir, + config, + nextVersion, + buildId, + }) { + // Process the build output + console.log('Build completed with', outputs.pages.length, 'pages') + console.log('Build ID:', buildId) + console.log('Dynamic routes:', routing.dynamicRoutes.length) + + // Access emitted output entries + for (const page of outputs.pages) { + console.log('Page:', page.pathname, 'at', page.filePath) + } + + for (const apiRoute of outputs.pagesApi) { + console.log('API Route:', apiRoute.pathname, 'at', apiRoute.filePath) + } + + for (const appPage of outputs.appPages) { + console.log('App Page:', appPage.pathname, 'at', appPage.filePath) + } + + for (const prerender of outputs.prerenders) { + console.log('Prerendered:', prerender.pathname) + } + }, +} + +module.exports = adapter +``` diff --git a/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx b/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx new file mode 100644 index 000000000000..4567c187bd4c --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx @@ -0,0 +1,39 @@ +--- +title: API Reference +description: Reference for `modifyConfig` and `onBuildComplete` in the `NextAdapter` interface. +--- + +## `async modifyConfig(config, context)` + +Called for any CLI command that loads the `next.config.js` file to allow modification of the configuration. + +**Parameters:** + +- `config`: The complete Next.js configuration object +- `context.phase`: The current build phase (see [phases](/docs/app/api-reference/config/next-config-js#phase)) +- `context.nextVersion`: Version of Next.js being used + +**Returns:** The modified configuration object (can be async) + +## `async onBuildComplete(context)` + +Called after the build process completes with detailed information about routes and outputs. + +**Parameters:** + +- `context.routing`: Object containing Next.js routing phases and metadata + - `routing.beforeMiddleware`: Routes executed before middleware (includes header and redirect handling) + - `routing.beforeFiles`: Rewrite routes checked before filesystem route matching + - `routing.afterFiles`: Rewrite routes checked after filesystem route matching + - `routing.dynamicRoutes`: Dynamic route matching table + - `routing.onMatch`: Routes applied after a successful match (for example immutable static asset cache headers) + - `routing.fallback`: Final rewrite fallback routes + - `routing.shouldNormalizeNextData`: Whether `/_next/data//...` URLs should be normalized during matching + - `routing.rsc`: Route metadata used for React Server Components routing behavior +- `context.outputs`: Detailed information about all build outputs organized by type +- `context.projectDir`: Absolute path to the Next.js project directory +- `context.repoRoot`: Absolute path to the detected repository root +- `context.distDir`: Absolute path to the build output directory +- `context.config`: The final Next.js configuration (with `modifyConfig` applied) +- `context.nextVersion`: Version of Next.js being used +- `context.buildId`: Unique identifier for the current build diff --git a/docs/01-app/03-api-reference/07-adapters/04-testing-adapters.mdx b/docs/01-app/03-api-reference/07-adapters/04-testing-adapters.mdx new file mode 100644 index 000000000000..61e2223f4432 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/04-testing-adapters.mdx @@ -0,0 +1,230 @@ +--- +title: Testing Adapters +description: Validate adapters with the Next.js compatibility test harness and custom lifecycle scripts. +--- + +Next.js provides a test harness for validating adapters. Running the end-to-end tests for deployment. + +Example GitHub Actions workflow: + +```yaml filename=".github/workflows/test-e2e-deploy.yml" +name: test-e2e-deploy + +on: + workflow_dispatch: + inputs: + nextjsRef: + description: 'Next.js repo ref (branch/tag/SHA)' + default: 'canary' + type: string + # schedule: + # - cron: '0 2 * * *' + +jobs: + build: + name: Build Next.js + adapter + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + path: adapter + + - uses: actions/checkout@v4 + with: + repository: vercel/next.js + ref: ${{ inputs.nextjsRef || 'canary' }} + path: nextjs + fetch-depth: 25 + + - uses: actions/setup-node@v4 + with: { node-version: '20' } + + - name: Setup pnpm + run: npm i -g corepack@0.31 && corepack enable + + - name: Install & build Next.js + working-directory: nextjs + run: pnpm install && pnpm build && pnpm install + + - name: Install Playwright + working-directory: nextjs + run: pnpm playwright install --with-deps chromium + + - name: Build adapter + working-directory: adapter + run: pnpm install && pnpm build + + - uses: actions/cache/save@v4 + with: + path: | + nextjs + adapter + ~/.cache/ms-playwright + key: build-${{ github.sha }}-${{ github.run_id }} + + test: + name: Tests (${{ matrix.group }}) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + group: + [ + 1/16, + 2/16, + 3/16, + 4/16, + 5/16, + 6/16, + 7/16, + 8/16, + 9/16, + 10/16, + 11/16, + 12/16, + 13/16, + 14/16, + 15/16, + 16/16, + ] + steps: + - uses: actions/cache/restore@v4 + with: + path: | + nextjs + adapter + ~/.cache/ms-playwright + key: build-${{ github.sha }}-${{ github.run_id }} + + - uses: actions/setup-node@v4 + with: { node-version: '20' } + + - name: Setup pnpm + run: npm i -g corepack@0.31 && corepack enable + + - name: Ensure Playwright browser + working-directory: nextjs + run: pnpm playwright install chromium + + - name: Make scripts executable + run: chmod +x adapter/scripts/e2e-deploy.sh + adapter/scripts/e2e-logs.sh + adapter/scripts/e2e-cleanup.sh + + - name: Run deploy tests + working-directory: nextjs + env: + NEXT_TEST_MODE: deploy + NEXT_E2E_TEST_TIMEOUT: 240000 + NEXT_EXTERNAL_TESTS_FILTERS: test/deploy-tests-manifest.json + ADAPTER_DIR: ${{ github.workspace }}/adapter + IS_TURBOPACK_TEST: 1 + NEXT_TEST_JOB: 1 + NEXT_TELEMETRY_DISABLED: 1 + + # Change these to your adapter's scripts + # Keep as-is if the scripts are in the adapter repository `scripts` directory + NEXT_TEST_DEPLOY_SCRIPT_PATH: ${{ github.workspace }}/adapter/scripts/e2e-deploy.sh + NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH: ${{ github.workspace }}/adapter/scripts/e2e-logs.sh + NEXT_TEST_CLEANUP_SCRIPT_PATH: ${{ github.workspace }}/adapter/scripts/e2e-cleanup.sh + run: node run-tests.js --timings -g ${{ matrix.group }} -c 2 --type e2e +``` + +The test harness looks for these environment variables: + +- `NEXT_TEST_DEPLOY_SCRIPT_PATH`: Path to the executable that builds and deploys the isolated test app +- `NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH`: Path to the executable that returns build and runtime logs for that deployment +- `NEXT_TEST_CLEANUP_SCRIPT_PATH`: Path to the optional executable that tears the deployment down after the test run + +## Custom deploy script contract + +The deploy script `NEXT_TEST_DEPLOY_SCRIPT_PATH` is executed with `cwd` set to the isolated temporary app created by the Next.js test harness. + +The deploy script must follow this contract: + +- Exit with a non-zero code on failure. +- Print the deployment URL to `stdout`. This will be used to verify the deployment. Avoid writing anything else to `stdout`. +- Write diagnostic output to `stderr` or to files inside the working directory. + +Because the deploy script and logs script run as separate processes, any data you want to use later, such as build IDs or server logs, should be persisted to files inside the working directory. + +Example deploy script: + +```bash filename="scripts/e2e-deploy.sh" +#!/usr/bin/env bash +set -euo pipefail + +# Install the adapter, build the app, and deploy or start it. +node -e " +const pkg=JSON.parse(require('fs').readFileSync('package.json','utf8')); +pkg.dependencies=pkg.dependencies||{}; +pkg.dependencies['adapter']='file:${ADAPTER_DIR}'; +require('fs').writeFileSync('package.json',JSON.stringify(pkg,null,2)); +" >&2 + +# Set the adapter path so that the app uses it. +export NEXT_ADAPTER_PATH="${ADAPTER_DIR}/dist/index.js" + +# Build the app +pnpm build + +# Write any metadata needed later to files in the working directory. +BUILD_ID="$(cat .next/BUILD_ID)" +DEPLOYMENT_ID="my-adapter-local" +# If your adapter generates an immutable asset token, set it here. +# Otherwise use "undefined" to indicate there is none. +IMMUTABLE_ASSET_TOKEN="undefined" + +{ + echo "BUILD_ID: $BUILD_ID" + echo "DEPLOYMENT_ID: $DEPLOYMENT_ID" + echo "IMMUTABLE_ASSET_TOKEN: $IMMUTABLE_ASSET_TOKEN" +} >> .adapter-build.log + +# Start or deploy the app. Capture the URL at this point or make the script output the URL to stdout. +provider-cli-to-deploy + +# Example URL output: +# echo "http://127.0.0.1:3000" +``` + +## Custom logs script contract + +The logs script `NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH` is executed with `cwd` set to the isolated temporary app created by the Next.js test harness. + +Additionally it receives `NEXT_TEST_DIR` and `NEXT_TEST_DEPLOY_URL` as environment variables. + +Its output must include lines starting with: + +- `BUILD_ID:` +- `DEPLOYMENT_ID:` +- `IMMUTABLE_ASSET_TOKEN:` (use the value `undefined` if your adapter does not produce one) + +After those markers, the logs script can print any additional build or server logs that would help debug failures. + +```bash filename="scripts/e2e-logs.sh" +#!/usr/bin/env bash +set -euo pipefail + +if [ -f ".adapter-build.log" ]; then + cat ".adapter-build.log" +fi + +if [ -f ".adapter-server.log" ]; then + echo "=== .adapter-server.log ===" + cat ".adapter-server.log" +fi +``` + +One pattern is to have the deploy script write `.adapter-build.log` and `.adapter-server.log`, then have the logs script replay those files so the harness can extract the required markers. This is one option, each platform has different ways to get the logs. + +## Custom cleanup script contract + +The cleanup script `NEXT_TEST_CLEANUP_SCRIPT_PATH` is executed with `cwd` set to the isolated temporary app created by the Next.js test harness. + +Additionally it receives `NEXT_TEST_DIR` and `NEXT_TEST_DEPLOY_URL` as environment variables. + +The cleanup script can be used to clean up any resources created by the deploy script. It runs after the tests have completed. diff --git a/docs/01-app/03-api-reference/07-adapters/05-routing-with-next-routing.mdx b/docs/01-app/03-api-reference/07-adapters/05-routing-with-next-routing.mdx new file mode 100644 index 000000000000..dc6dd8ff49a9 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/05-routing-with-next-routing.mdx @@ -0,0 +1,56 @@ +--- +title: Routing with @next/routing +description: Use `@next/routing` to apply Next.js route matching behavior in adapters. +--- + +You can use [`@next/routing`](https://www.npmjs.com/package/@next/routing) to reproduce Next.js route matching behavior with data from `onBuildComplete`. + +> [!NOTE] +> `@next/routing` is experimental and will stabilize with the adapters API. + +```typescript +import { resolveRoutes } from '@next/routing' + +const pathnames = [ + ...outputs.pages, + ...outputs.pagesApi, + ...outputs.appPages, + ...outputs.appRoutes, + ...outputs.staticFiles, +].map((output) => output.pathname) + +const result = await resolveRoutes({ + url: new URL(requestUrl), + buildId, + basePath: config.basePath || '', + i18n: config.i18n, + headers: new Headers(requestHeaders), + requestBody, // ReadableStream + pathnames, + routes: routing, + invokeMiddleware: async (ctx) => { + // platform-specific middleware invocation + return {} + }, +}) + +if (result.resolvedPathname) { + console.log('Resolved pathname:', result.resolvedPathname) + console.log('Resolved query:', result.resolvedQuery) + console.log('Invocation target:', result.invocationTarget) +} +``` + +`resolveRoutes()` returns: + +- `middlewareResponded`: `true` when middleware already sent a response (the adapter should not invoke an entrypoint). +- `externalRewrite`: A `URL` when routing resolved to an external rewrite destination. +- `redirect`: An object with `url` (`URL`) and `status` when the request should be redirected. +- `resolvedPathname`: The route pathname selected by Next.js routing. For dynamic routes, this is the matched route template such as `/blog/[slug]`. +- `resolvedQuery`: The final query after rewrites or middleware have added or replaced search params. +- `invocationTarget`: The concrete pathname and query to invoke for the matched route. +- `resolvedHeaders`: A `Headers` object containing any headers added or modified during routing. +- `status`: An HTTP status code set by routing (for example from a redirect or rewrite rule). +- `routeMatches`: A record of named matches extracted from dynamic route segments. + +For example, if `/blog/post-1?draft=1` matches `/blog/[slug]?slug=post-1`, `resolvedPathname` is `/blog/[slug]` while `invocationTarget.pathname` is `/blog/post-1`. diff --git a/docs/01-app/03-api-reference/07-adapters/06-implementing-ppr-in-an-adapter.mdx b/docs/01-app/03-api-reference/07-adapters/06-implementing-ppr-in-an-adapter.mdx new file mode 100644 index 000000000000..53d8d9ef85c3 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/06-implementing-ppr-in-an-adapter.mdx @@ -0,0 +1,113 @@ +--- +title: Implementing PPR in an Adapter +description: Implement Partial Prerendering support in an adapter using fallback output and cache hooks. +--- + +For partially prerendered app routes, `onBuildComplete` gives you the data needed to seed and resume PPR: + +- `outputs.prerenders[].fallback.filePath`: path to the generated fallback shell (for example HTML) +- `outputs.prerenders[].fallback.postponedState`: serialized postponed state used to resume rendering + +## 1. Seed shell + postponed state at build time + +```ts filename="my-adapter.ts" +import { readFile } from 'node:fs/promises' + +async function seedPprEntries(outputs: AdapterOutputs) { + for (const prerender of outputs.prerenders) { + const fallback = prerender.fallback + if (!fallback?.filePath || !fallback.postponedState) continue + + const shell = await readFile(fallback.filePath, 'utf8') + await platformCache.set(prerender.pathname, { + shell, + postponedState: fallback.postponedState, + initialHeaders: fallback.initialHeaders, + initialStatus: fallback.initialStatus, + initialRevalidate: fallback.initialRevalidate, + initialExpiration: fallback.initialExpiration, + }) + } +} +``` + +## 2. Runtime flow: serve cached shell and resume in background + +At request time, you can stream a single response that is the concatenation of: + +1. cached HTML shell stream +2. resumed render stream (generated after invoking `handler` with postponed state) + +```text +Client + | GET /ppr-route + v +Adapter Router + | + |-- read cached shell + postponedState ---> Platform Cache + |<------------- cache hit -----------------| + | + |-- create responseStream = concat(shellStream, resumedStream) + | + |-- start piping shellStream ------------> Client (first bytes) + | + |-- invoke handler(req, res, { requestMeta: { postponed } }) + | -------------------------------------> Entrypoint (handler) + | <------------------------------------- resumed chunks/cache entry + | + |-- append resumed chunks to resumedStream + | + '-- client receives one HTTP response: + [shell bytes........][resumed bytes........] +``` + +## 3. Update cache with `requestMeta.onCacheEntryV2` + +`requestMeta.onCacheEntryV2` is called when a response cache entry is looked up or generated. Use it to persist updated shell/postponed data. + +> [!NOTE] +> +> - `requestMeta.onCacheEntry` still works, but is deprecated. +> - Prefer `requestMeta.onCacheEntryV2`. +> - If your adapter uses an internal `onCacheCallback` abstraction, wire it to `requestMeta.onCacheEntryV2`. + +```ts filename="my-adapter.ts" +await handler(req, res, { + waitUntil, + requestMeta: { + postponed: cachedPprEntry?.postponedState, + onCacheEntryV2: async (cacheEntry, meta) => { + if (cacheEntry.value?.kind === 'APP_PAGE') { + const html = + cacheEntry.value.html && + typeof cacheEntry.value.html.toUnchunkedString === 'function' + ? cacheEntry.value.html.toUnchunkedString() + : null + + await platformCache.set(meta.url || req.url || '/', { + shell: html, + postponedState: cacheEntry.value.postponed, + headers: cacheEntry.value.headers, + status: cacheEntry.value.status, + cacheControl: cacheEntry.cacheControl, + }) + } + + // Return true only if your adapter already wrote the response itself. + return false + }, + }, +}) +``` + +```text +Entrypoint (handler) + | onCacheEntryV2(cacheEntry, { url }) + v +requestMeta.onCacheEntryV2 callback + | + |-- if APP_PAGE ---> persist html + postponedState + headers ---> Platform Cache + | + '-- return false: continue normal Next.js response flow + return true: adapter already handled response (short-circuit) +``` diff --git a/docs/01-app/03-api-reference/07-adapters/07-runtime-integration.mdx b/docs/01-app/03-api-reference/07-adapters/07-runtime-integration.mdx new file mode 100644 index 000000000000..1a02529447e5 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/07-runtime-integration.mdx @@ -0,0 +1,30 @@ +--- +title: Runtime Integration +description: Understand how build-time adapters and runtime cache interfaces work together. +--- + +The Deployment Adapter API is a **build-time** interface. It tells your platform what was built and how to route requests. **Runtime** behavior (request handling, streaming, caching) is handled by the Next.js server itself and by the cache interfaces [`cacheHandler`](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath) and [`cacheHandlers`](/docs/app/api-reference/config/next-config-js/cacheHandlers). + +Together, the adapter and cache interfaces form the complete platform integration surface: + +- **Adapter** (build-time): processes build outputs, configures routing, and sets up platform-specific infrastructure. +- **Cache Interfaces** (runtime): `cacheHandler` manages ISR/server cache storage and revalidation across instances; `cacheHandlers` configures `'use cache'` directive backends and tag coordination. + +## Handler Context + +When invoking entrypoints, adapters pass a `ctx` object to the Next.js handler. Key fields include: + +- **`ctx.waitUntil`**: a function that accepts a promise. Use this to keep the serverless function alive after the response is sent, allowing background work like cache revalidation to complete. +- **`requestMeta.onCacheEntryV2`** (set via `addRequestMeta`): a callback that fires when a cache entry is generated or looked up. Use this to observe all cache operations (not just PPR) and propagate cache updates to your platform's storage backend. This callback fires on the instance that handled the request. For multi-instance deployments, your adapter should propagate updates to shared storage. See [How Revalidation Works](/docs/app/guides/how-revalidation-works) for coordination patterns. + +## PPR Chain Headers + +In the [prerenders output type](/docs/app/api-reference/adapters/output-types#prerenders-outputsprerenders), `pprChain.headers` contains the headers needed for the [resume protocol](/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter). Specifically, it contains `{ 'next-resume': '1' }`. + +When your adapter detects a PPR-enabled route with a cached static shell: + +1. Set the `pprChain.headers` on the internal request to the Next.js handler. +2. Send the request as a **POST** with the `postponedState` as the request body. +3. The handler will render only the deferred Suspense boundaries and stream the result. + +> **Good to know:** In standard `next start`, the server handles both the shell and dynamic render in a single pass automatically. The resume protocol is useful for adapter-based deployments and CDN-to-origin architectures that want to serve the shell separately. See the [PPR Platform Guide](/docs/app/guides/ppr-platform-guide) for the full implementation context. diff --git a/docs/01-app/03-api-reference/07-adapters/08-invoking-entrypoints.mdx b/docs/01-app/03-api-reference/07-adapters/08-invoking-entrypoints.mdx new file mode 100644 index 000000000000..5fd30e9f44e9 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/08-invoking-entrypoints.mdx @@ -0,0 +1,93 @@ +--- +title: Invoking Entrypoints +description: Invoke Node.js and Edge build entrypoints with adapter runtime context. +--- + +Build output entrypoints use a `handler(..., ctx)` interface, with runtime-specific request/response types. + +## Node.js runtime (`runtime: 'nodejs'`) + +Node.js entrypoints use the following interface: + +```typescript +handler( + req: IncomingMessage, + res: ServerResponse, + ctx: { + waitUntil?: (promise: Promise) => void + requestMeta?: RequestMeta + } +): Promise +``` + +When invoking Node.js entrypoints directly, adapters can pass helpers directly on `requestMeta` instead of relying on internals. Some of the supported fields are `hostname`, +`revalidate`, and `render404`: + +```ts +await handler(req, res, { + requestMeta: { + // Relative path from process.cwd() to the Next.js project directory. + relativeProjectDir: '.', + // Optional hostname used by route handlers when constructing absolute URLs. + hostname: '127.0.0.1', + // Optional internal revalidate function to avoid revalidating over the network + revalidate: async ({ urlPath, headers, opts }) => { + // platform-specific revalidate implementation + }, + // Optional function to render the 404 page for pages router `notFound: true` + render404: async (req, res, parsedUrl, setHeaders) => { + // platform-specific 404 rendering implementation + }, + }, +}) +``` + +Relevant files in the Next.js core: + +- [`packages/next/src/build/templates/app-page.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/app-page.ts) +- [`packages/next/src/build/templates/app-route.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/app-route.ts) +- and [`packages/next/src/build/templates/pages-api.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/pages-api.ts) + +## Edge runtime (`runtime: 'edge'`) + +Edge entrypoints use the following interface: + +```typescript +handler( + request: Request, + ctx: { + waitUntil?: (prom: Promise) => void + signal?: AbortSignal + requestMeta?: RequestMeta + } +): Promise +``` + +The shape is aligned around `handler(..., ctx)`, but Node.js and Edge runtimes use different request/response primitives. + +For outputs with `runtime: 'edge'`, Next.js also provides `output.edgeRuntime` with the canonical metadata needed to invoke the entrypoint: + +```typescript +{ + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' +} +``` + +After your edge runtime loads and evaluates the chunks for `modulePath`, use `entryKey` to read the registered entry from the global edge entry registry (`globalThis._ENTRIES`), then invoke `handlerExport` from that entry: + +```ts +const entry = await globalThis._ENTRIES[output.edgeRuntime.entryKey] +const handler = entry[output.edgeRuntime.handlerExport] +await handler(request, ctx) +``` + +Use `edgeRuntime` instead of deriving registry keys or handler names from filenames. + +Relevant files in the Next.js core: + +- [`packages/next/src/build/templates/edge-ssr.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/edge-ssr.ts) +- [`packages/next/src/build/templates/edge-app-route.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/edge-app-route.ts) +- [`packages/next/src/build/templates/pages-edge-api.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/pages-edge-api.ts) +- and [`packages/next/src/build/templates/middleware.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/middleware.ts) diff --git a/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx b/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx new file mode 100644 index 000000000000..a463d9bed404 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx @@ -0,0 +1,207 @@ +--- +title: Output Types +description: Reference for all build output types exposed to adapters. +--- + +The `outputs` object contains arrays of build output types: + +- `outputs.pages`: React pages from the `pages/` directory +- `outputs.pagesApi`: API routes from `pages/api/` +- `outputs.appPages`: React pages from the `app/` directory +- `outputs.appRoutes`: API and metadata routes from `app/` +- `outputs.prerenders`: ISR-enabled routes and static prerenders +- `outputs.staticFiles`: Static assets and auto-statically optimized pages +- `outputs.middleware`: Middleware function (if present) + +> **Note:** When `config.output` is set to `'export'`, only `outputs.staticFiles` is populated. All other arrays (`pages`, `appPages`, `pagesApi`, `appRoutes`, `prerenders`) will be empty since the entire application is exported as static files. + +For any route output with `runtime: 'edge'`, `edgeRuntime` is included and contains the canonical entry metadata for invoking that output in your edge runtime. + +## Pages (`outputs.pages`) + +React pages from the `pages/` directory: + +```typescript +{ + type: 'PAGES' + id: string // Route identifier + filePath: string // Path to the built file + pathname: string // URL pathname + sourcePage: string // Original source file path in pages/ directory + runtime: 'nodejs' | 'edge' + assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } + config: { + maxDuration?: number // Maximum duration of the route in seconds + preferredRegion?: string | string[] // Preferred deployment region + env?: Record // Environment variables (edge runtime only) + } +} +``` + +## API Routes (`outputs.pagesApi`) + +API routes from `pages/api/`: + +```typescript +{ + type: 'PAGES_API' + id: string // Route identifier + filePath: string // Path to the built file + pathname: string // URL pathname + sourcePage: string // Original relative source file path + runtime: 'nodejs' | 'edge' + assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } + config: { + maxDuration?: number // Maximum duration of the route in seconds + preferredRegion?: string | string[] // Preferred deployment region + env?: Record // Environment variables (edge runtime only) + } +} +``` + +## App Pages (`outputs.appPages`) + +React pages from the `app/` directory: + +```typescript +{ + type: 'APP_PAGE' + id: string // Route identifier + filePath: string // Path to the built file + pathname: string // URL pathname. Includes .rsc suffix for RSC routes + sourcePage: string // Original relative source file path + runtime: 'nodejs' | 'edge' // Runtime the route is built for + assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } + config: { + maxDuration?: number // Maximum duration of the route in seconds + preferredRegion?: string | string[] // Preferred deployment region + env?: Record // Environment variables (edge runtime only) + } +} +``` + +## App Routes (`outputs.appRoutes`) + +API and metadata routes from the `app/` directory: + +```typescript +{ + type: 'APP_ROUTE' + id: string // Route identifier + filePath: string // Path to the built file + pathname: string // URL pathname + sourcePage: string // Original relative source file path + runtime: 'nodejs' | 'edge' // Runtime the route is built for + assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } + config: { + maxDuration?: number // Maximum duration of the route in seconds + preferredRegion?: string | string[] // Preferred deployment region + env?: Record // Environment variables (edge runtime only) + } +} +``` + +## Prerenders (`outputs.prerenders`) + +ISR-enabled routes and static prerenders: + +```typescript +{ + type: 'PRERENDER' + id: string // Route identifier + pathname: string // URL pathname + parentOutputId: string // ID of the source page/route + groupId: number // Revalidation group identifier (prerenders with same groupId revalidate together) + pprChain?: { + headers: Record // PPR chain headers (e.g., 'next-resume': '1') + } + parentFallbackMode?: false | null | string // false: no additional paths (fallback: false), null: blocking render, string: path to HTML fallback + fallback?: { + filePath: string | undefined // Path to the fallback file (HTML, JSON, or RSC) + initialStatus?: number // Initial status code + initialHeaders?: Record // Initial headers + initialExpiration?: number // Initial expiration time in seconds + initialRevalidate?: number | false // Initial revalidate time in seconds, or false for fully static + postponedState: string | undefined // Serialized PPR state used for resuming rendering + } + config: { + allowQuery?: string[] // Allowed query parameters considered for the cache key + allowHeader?: string[] // Allowed headers for ISR + bypassFor?: RouteHas[] // Cache bypass conditions + renderingMode?: 'STATIC' | 'PARTIALLY_STATIC' // STATIC: fully static, PARTIALLY_STATIC: PPR-enabled + partialFallback?: boolean // Serves a partial fallback shell that should be upgraded to a full route in the background + bypassToken?: string // Generated token that signals the prerender cache should be bypassed + } +} +``` + +## Static Files (`outputs.staticFiles`) + +Static assets and auto-statically optimized pages: + +```typescript +{ + type: 'STATIC_FILE' + id: string // Route identifier + filePath: string // Path to the built file + pathname: string // URL pathname + immutableHash: string | undefined // Content hash when the filename contains a hash, indicating the file is immutable +} +``` + +## Middleware (`outputs.middleware`) + +`middleware.ts` (`.js`/`.ts`) or `proxy.ts` (`.js`/`.ts`) function (if present): + +```typescript +{ + type: 'MIDDLEWARE' + id: string // Route identifier + filePath: string // Path to the built file + pathname: string // Always '/_middleware' + sourcePage: string // Always 'middleware' + runtime: 'nodejs' | 'edge' // Runtime the route is built for + assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } + config: { + maxDuration?: number // Maximum duration of the route in seconds + preferredRegion?: string | string[] // Preferred deployment region + env?: Record // Environment variables (edge runtime only) + matchers?: Array<{ + source: string // Source pattern + sourceRegex: string // Compiled regex for matching requests + has: RouteHas[] | undefined // Positive matching conditions + missing: RouteHas[] | undefined // Negative matching conditions + }> + } +} +``` diff --git a/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx b/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx new file mode 100644 index 000000000000..e061dc153618 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx @@ -0,0 +1,43 @@ +--- +title: Routing Information +description: Reference for routing phases and route fields exposed in `onBuildComplete`. +--- + +The `routing` object in `onBuildComplete` provides complete routing information with processed patterns ready for deployment: + +## `routing.beforeMiddleware` + +Routes applied before middleware execution. These include generated header and redirect behavior. + +## `routing.beforeFiles` + +Rewrite routes checked before filesystem route matching. + +## `routing.afterFiles` + +Rewrite routes checked after filesystem route matching. + +## `routing.dynamicRoutes` + +Dynamic matchers generated from route segments such as `[slug]` and catch-all routes. + +## `routing.onMatch` + +Routes that apply after a successful match, such as immutable cache headers for hashed static assets. + +## `routing.fallback` + +Final rewrite routes checked when earlier phases did not produce a match. + +## Common Route Fields + +Each route entry can include: + +- `source`: Original route pattern (optional for generated internal rules) +- `sourceRegex`: Compiled regex for matching requests +- `destination`: Internal destination or redirect destination +- `headers`: Headers to apply +- `has`: Positive matching conditions +- `missing`: Negative matching conditions +- `status`: Redirect status code +- `priority`: Internal route priority flag diff --git a/docs/01-app/03-api-reference/07-adapters/11-use-cases.mdx b/docs/01-app/03-api-reference/07-adapters/11-use-cases.mdx new file mode 100644 index 000000000000..4dca8c46c939 --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/11-use-cases.mdx @@ -0,0 +1,13 @@ +--- +title: Use Cases +description: Common patterns and examples for deployment adapter implementations. +--- + +Common use cases for adapters include: + +- **Deployment Platform Integration**: Automatically configure build outputs for specific hosting platforms +- **Asset Processing**: Transform or optimize build outputs +- **Monitoring Integration**: Collect build metrics and route information +- **Custom Bundling**: Package outputs in platform-specific formats +- **Build Validation**: Ensure outputs meet specific requirements +- **Route Generation**: Use processed route information to generate platform-specific routing configs diff --git a/docs/01-app/03-api-reference/07-adapters/index.mdx b/docs/01-app/03-api-reference/07-adapters/index.mdx new file mode 100644 index 000000000000..5d130498db7a --- /dev/null +++ b/docs/01-app/03-api-reference/07-adapters/index.mdx @@ -0,0 +1,18 @@ +--- +title: Adapters +description: Build deployment adapters for Next.js platforms and infrastructure. +--- + +Use this section to build and validate deployment adapters that integrate with the Next.js build and runtime model. + +- [Configuration](/docs/app/api-reference/adapters/configuration) +- [Creating an Adapter](/docs/app/api-reference/adapters/creating-an-adapter) +- [API Reference](/docs/app/api-reference/adapters/api-reference) +- [Testing Adapters](/docs/app/api-reference/adapters/testing-adapters) +- [Routing with `@next/routing`](/docs/app/api-reference/adapters/routing-with-next-routing) +- [Implementing PPR in an Adapter](/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter) +- [Runtime Integration](/docs/app/api-reference/adapters/runtime-integration) +- [Invoking Entrypoints](/docs/app/api-reference/adapters/invoking-entrypoints) +- [Output Types](/docs/app/api-reference/adapters/output-types) +- [Routing Information](/docs/app/api-reference/adapters/routing-information) +- [Use Cases](/docs/app/api-reference/adapters/use-cases) diff --git a/docs/02-pages/04-api-reference/04-config/01-next-config-js/adapterPath.mdx b/docs/02-pages/04-api-reference/04-config/01-next-config-js/adapterPath.mdx index 899cdd7f0660..9143d16afed3 100644 --- a/docs/02-pages/04-api-reference/04-config/01-next-config-js/adapterPath.mdx +++ b/docs/02-pages/04-api-reference/04-config/01-next-config-js/adapterPath.mdx @@ -1,6 +1,6 @@ --- title: adapterPath -description: Configure a custom adapter for Next.js to hook into the build process with modifyConfig and buildComplete callbacks. +description: Configure a custom adapter for Next.js to hook into the build process. source: app/api-reference/config/next-config-js/adapterPath --- diff --git a/docs/02-pages/04-api-reference/06-adapters/01-configuration.mdx b/docs/02-pages/04-api-reference/06-adapters/01-configuration.mdx new file mode 100644 index 000000000000..77b888d8fbc5 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/01-configuration.mdx @@ -0,0 +1,7 @@ +--- +title: Configuration +description: Configure `adapterPath` or `NEXT_ADAPTER_PATH` to use a custom deployment adapter. +source: app/api-reference/adapters/configuration +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/02-creating-an-adapter.mdx b/docs/02-pages/04-api-reference/06-adapters/02-creating-an-adapter.mdx new file mode 100644 index 000000000000..de9c4a5c5ac2 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/02-creating-an-adapter.mdx @@ -0,0 +1,7 @@ +--- +title: Creating an Adapter +description: Create an adapter module that implements the `NextAdapter` interface. +source: app/api-reference/adapters/creating-an-adapter +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/03-api-reference.mdx b/docs/02-pages/04-api-reference/06-adapters/03-api-reference.mdx new file mode 100644 index 000000000000..05abfb3907a1 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/03-api-reference.mdx @@ -0,0 +1,7 @@ +--- +title: API Reference +description: Reference for `modifyConfig` and `onBuildComplete` in the `NextAdapter` interface. +source: app/api-reference/adapters/api-reference +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/04-testing-adapters.mdx b/docs/02-pages/04-api-reference/06-adapters/04-testing-adapters.mdx new file mode 100644 index 000000000000..e2f72d196385 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/04-testing-adapters.mdx @@ -0,0 +1,7 @@ +--- +title: Testing Adapters +description: Validate adapters with the Next.js compatibility test harness and custom lifecycle scripts. +source: app/api-reference/adapters/testing-adapters +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/05-routing-with-next-routing.mdx b/docs/02-pages/04-api-reference/06-adapters/05-routing-with-next-routing.mdx new file mode 100644 index 000000000000..750b9441fc98 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/05-routing-with-next-routing.mdx @@ -0,0 +1,7 @@ +--- +title: Routing with @next/routing +description: Use `@next/routing` to apply Next.js route matching behavior in adapters. +source: app/api-reference/adapters/routing-with-next-routing +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/06-implementing-ppr-in-an-adapter.mdx b/docs/02-pages/04-api-reference/06-adapters/06-implementing-ppr-in-an-adapter.mdx new file mode 100644 index 000000000000..7954166700ad --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/06-implementing-ppr-in-an-adapter.mdx @@ -0,0 +1,7 @@ +--- +title: Implementing PPR in an Adapter +description: Implement Partial Prerendering support in an adapter using fallback output and cache hooks. +source: app/api-reference/adapters/implementing-ppr-in-an-adapter +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/07-runtime-integration.mdx b/docs/02-pages/04-api-reference/06-adapters/07-runtime-integration.mdx new file mode 100644 index 000000000000..c9da7c119c71 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/07-runtime-integration.mdx @@ -0,0 +1,7 @@ +--- +title: Runtime Integration +description: Understand how build-time adapters and runtime cache interfaces work together. +source: app/api-reference/adapters/runtime-integration +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/08-invoking-entrypoints.mdx b/docs/02-pages/04-api-reference/06-adapters/08-invoking-entrypoints.mdx new file mode 100644 index 000000000000..08f43958cb11 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/08-invoking-entrypoints.mdx @@ -0,0 +1,7 @@ +--- +title: Invoking Entrypoints +description: Invoke Node.js and Edge build entrypoints with adapter runtime context. +source: app/api-reference/adapters/invoking-entrypoints +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/09-output-types.mdx b/docs/02-pages/04-api-reference/06-adapters/09-output-types.mdx new file mode 100644 index 000000000000..30f3b56a38b1 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/09-output-types.mdx @@ -0,0 +1,7 @@ +--- +title: Output Types +description: Reference for all build output types exposed to adapters. +source: app/api-reference/adapters/output-types +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/10-routing-information.mdx b/docs/02-pages/04-api-reference/06-adapters/10-routing-information.mdx new file mode 100644 index 000000000000..294c143d2fdd --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/10-routing-information.mdx @@ -0,0 +1,7 @@ +--- +title: Routing Information +description: Reference for routing phases and route fields exposed in `onBuildComplete`. +source: app/api-reference/adapters/routing-information +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/11-use-cases.mdx b/docs/02-pages/04-api-reference/06-adapters/11-use-cases.mdx new file mode 100644 index 000000000000..f26905668294 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/11-use-cases.mdx @@ -0,0 +1,7 @@ +--- +title: Use Cases +description: Common patterns and examples for deployment adapter implementations. +source: app/api-reference/adapters/use-cases +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/docs/02-pages/04-api-reference/06-adapters/index.mdx b/docs/02-pages/04-api-reference/06-adapters/index.mdx new file mode 100644 index 000000000000..6bf6e68aa010 --- /dev/null +++ b/docs/02-pages/04-api-reference/06-adapters/index.mdx @@ -0,0 +1,7 @@ +--- +title: Adapters +description: Build deployment adapters for Next.js platforms and infrastructure. +source: app/api-reference/adapters +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the Content component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/lerna.json b/lerna.json index fd4a1b764cee..c77a789e9336 100644 --- a/lerna.json +++ b/lerna.json @@ -10,10 +10,11 @@ "publish": { "npmClient": "npm", "allowBranch": [ - "canary" + "canary", + "next-16-2" ], "registry": "https://registry.npmjs.org/" } }, - "version": "16.2.0" + "version": "16.2.6" } \ No newline at end of file diff --git a/package.json b/package.json index 1002f6b9411f..d61c7bbdb4a4 100644 --- a/package.json +++ b/package.json @@ -273,10 +273,10 @@ "react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-3f0b9e61-20260317", "react-experimental-builtin": "npm:react@0.0.0-experimental-3f0b9e61-20260317", "react-is-builtin": "npm:react-is@19.3.0-canary-3f0b9e61-20260317", - "react-server-dom-turbopack": "npm:react-server-dom-turbopack@19.3.0-canary-3f0b9e61-20260317", - "react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-3f0b9e61-20260317", - "react-server-dom-webpack": "npm:react-server-dom-webpack@19.3.0-canary-3f0b9e61-20260317", - "react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-3f0b9e61-20260317", + "react-server-dom-turbopack": "file:packages/next/src/compiled/react-server-dom-turbopack", + "react-server-dom-turbopack-experimental": "file:packages/next/src/compiled/react-server-dom-turbopack-experimental", + "react-server-dom-webpack": "file:packages/next/src/compiled/react-server-dom-webpack", + "react-server-dom-webpack-experimental": "file:packages/next/src/compiled/react-server-dom-webpack-experimental", "react-ssr-prepass": "1.0.8", "react-virtualized": "9.22.3", "relay-compiler": "13.0.2", diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index 313086c76d90..6cdc2977103b 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -251,19 +251,45 @@ async function run(): Promise { type DisplayConfigItem = { key: keyof typeof defaults values?: Record + flags?: Record } const displayConfig: DisplayConfigItem[] = [ { key: 'typescript', values: { true: 'TypeScript', false: 'JavaScript' }, + flags: { true: '--ts', false: '--js' }, + }, + { + key: 'linter', + values: { eslint: 'ESLint', biome: 'Biome', none: 'None' }, + flags: { eslint: '--eslint', biome: '--biome', none: '--no-eslint' }, + }, + { + key: 'reactCompiler', + values: { true: 'React Compiler', false: 'No React Compiler' }, + flags: { true: '--react-compiler', false: '--no-react-compiler' }, + }, + { + key: 'tailwind', + values: { true: 'Tailwind CSS', false: 'No Tailwind CSS' }, + flags: { true: '--tailwind', false: '--no-tailwind' }, + }, + { + key: 'srcDir', + values: { true: 'src/ directory', false: 'No src/ directory' }, + flags: { true: '--src-dir', false: '--no-src-dir' }, + }, + { + key: 'app', + values: { true: 'App Router', false: 'Pages Router' }, + flags: { true: '--app', false: '--no-app' }, + }, + { + key: 'agentsMd', + values: { true: 'AGENTS.md', false: 'No AGENTS.md' }, + flags: { true: '--agents-md', false: '--no-agents-md' }, }, - { key: 'linter', values: { eslint: 'ESLint', biome: 'Biome' } }, - { key: 'reactCompiler', values: { true: 'React Compiler' } }, - { key: 'tailwind', values: { true: 'Tailwind CSS' } }, - { key: 'srcDir', values: { true: 'src/ dir' } }, - { key: 'app', values: { true: 'App Router', false: 'Pages Router' } }, - { key: 'agentsMd', values: { true: 'AGENTS.md' } }, ] // Helper to format settings for display based on displayConfig @@ -291,10 +317,17 @@ async function run(): Promise { const hasSavedPreferences = Object.keys(preferences).length > 0 // Check if user provided any configuration flags - // If they did, skip the "recommended defaults" prompt and go straight to - // individual prompts for any missing options + // If they did, skip all prompts and use recommended defaults for unspecified + // options. This is critical for AI agents, which pass flags like + // --typescript --tailwind --app and expect the rest to use sensible defaults + // without entering interactive mode. const hasProvidedOptions = process.argv.some((arg) => arg.startsWith('--')) + if (!skipPrompt && hasProvidedOptions) { + skipPrompt = true + useRecommendedDefaults = true + } + // Only show the "recommended defaults" prompt if: // - Not in CI and not using --yes flag // - User hasn't provided any custom options @@ -620,6 +653,56 @@ async function run(): Promise { preferences.agentsMd = Boolean(agentsMd) } } + + // When prompts were skipped because flags were provided, print the + // defaults that were assumed so agents and users know what to override. + if (hasProvidedOptions && useRecommendedDefaults) { + const lines: string[] = [] + + for (const config of displayConfig) { + if (!config.flags || !config.values) continue + + // Skip options the user already specified explicitly + const wasExplicit = process.argv.some((arg) => + Object.values(config.flags!).includes(arg) + ) + if (wasExplicit) continue + + const value = String(defaults[config.key]) + const flag = config.flags[value] + const label = config.values[value] + if (!flag || !label) continue + + // Show alternatives the user could pass instead + const alts: string[] = [] + for (const [k, f] of Object.entries(config.flags)) { + if (k !== value && config.values[k]) { + alts.push(`${f} for ${config.values[k]}`) + } + } + + const altText = alts.length > 0 ? ` (use ${alts.join(', ')})` : '' + lines.push(` ${flag.padEnd(24)}${label}${altText}`) + } + + // Import alias is not a boolean toggle, handle separately + const hasImportAlias = process.argv.some( + (arg) => + arg.startsWith('--import-alias') || + arg.startsWith('--no-import-alias') + ) + if (!hasImportAlias) { + lines.push(` ${'--import-alias'.padEnd(24)}"${defaults.importAlias}"`) + } + + if (lines.length > 0) { + console.log( + '\nUsing defaults for unprovided options:\n\n' + + lines.join('\n') + + '\n' + ) + } + } } const bundler: Bundler = opts.rspack ? Bundler.Rspack : Bundler.Turbopack diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index be9a9d2ca7e2..2edd1acf48c6 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.2.0", + "version": "16.2.6", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index d2f61e32ec4c..2f96fead9fa0 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.2.0", + "version": "16.2.6", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.2.0", + "@next/eslint-plugin-next": "16.2.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 507806c42933..12ba2a39294d 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.2.0", + "version": "16.2.6", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 5973444c4b8c..06b84cc4f1c8 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.2.0", + "version": "16.2.6", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 95bed12ef435..d7cb0a517bf0 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.2.0", + "version": "16.2.6", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 059bfdcaec07..72f86c94a195 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.2.0", + "version": "16.2.6", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 8e43e6da3f01..44f2d1991000 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.2.0", + "version": "16.2.6", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 60fb70612adb..d4038cb28980 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.2.0", + "version": "16.2.6", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 7dd5fa409526..a4e22d8e2b5f 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.2.0", + "version": "16.2.6", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 8e3dec1dad09..bb59dd893098 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.2.0", + "version": "16.2.6", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 412da2a8865b..9d1ae0f81ac3 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.2.0", + "version": "16.2.6", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 8fa43c3783e3..604072b94a4d 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.2.0", + "version": "16.2.6", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index b60904973141..fec2b705ad33 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.2.0", + "version": "16.2.6", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 66ea93eb1d9f..12ca8fad4304 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.2.0", + "version": "16.2.6", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 55b1ebea6b91..d9642aa402b9 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.2.0", + "version": "16.2.6", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index b9b4341d6a02..668e0e907457 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.2.0", + "version": "16.2.6", "private": true, "files": [ "native/" diff --git a/packages/next/errors.json b/packages/next/errors.json index d526e5785a5b..4411eb7e5adf 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1140,5 +1140,6 @@ "1139": "`unstable_catchError` can only be used in Client Components.", "1140": "Route %s used \\`import('next/root-params').%s()\\` inside \\`\"use cache\"\\` nested within \\`unstable_cache\\`. Root params are not available in this context.", "1141": "Route %s used \\`import('next/root-params').%s()\\` inside \\`unstable_cache\\`. This is not supported. Use \\`\"use cache\"\\` instead.", - "1142": "Route %s used \\`import('next/root-params').%s()\\` inside \\`generateStaticParams\\`, but the \\`%s\\` parameter was not provided by a parent \\`generateStaticParams\\`. In \\`generateStaticParams\\`, root params are only available for segments nested below the segment that provides them." + "1142": "Route %s used \\`import('next/root-params').%s()\\` inside \\`generateStaticParams\\`, but the \\`%s\\` parameter was not provided by a parent \\`generateStaticParams\\`. In \\`generateStaticParams\\`, root params are only available for segments nested below the segment that provides them.", + "1143": "Response body exceeded maximum size of %s bytes" } diff --git a/packages/next/package.json b/packages/next/package.json index 3d4ff6c69463..7f49c27cc83a 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.2.0", + "version": "16.2.6", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.2.0", + "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -161,11 +161,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.0", - "@next/polyfill-module": "16.2.0", - "@next/polyfill-nomodule": "16.2.0", - "@next/react-refresh-utils": "16.2.0", - "@next/swc": "16.2.0", + "@next/font": "16.2.6", + "@next/polyfill-module": "16.2.6", + "@next/polyfill-nomodule": "16.2.6", + "@next/react-refresh-utils": "16.2.6", + "@next/swc": "16.2.6", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.58.2", "@rspack/core": "1.6.7", diff --git a/packages/next/src/bin/next.ts b/packages/next/src/bin/next.ts index d08775192b38..3df1cf6847a8 100755 --- a/packages/next/src/bin/next.ts +++ b/packages/next/src/bin/next.ts @@ -313,7 +313,14 @@ program '--experimental-https-ca, ', 'Path to a HTTPS certificate authority file.' ) - .option('--no-server-fast-refresh', 'Disable server-side Fast Refresh') + // `--server-fast-refresh` is hidden because it's the default behavior and + // only needs to be explicitly passed to override a + // `experimental.turbopackServerFastRefresh: false` in next.config. The + // `--no-server-fast-refresh` negation is the meaningful user-facing flag. + .addOption(new Option('--server-fast-refresh').default(undefined).hideHelp()) + .addOption( + new Option('--no-server-fast-refresh', 'Disable server-side Fast Refresh') + ) .option( '--experimental-upload-trace, ', 'Reports a subset of the debugging trace to a remote HTTP URL. Includes sensitive data.' diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts index 32cded960f5b..0a801a8a6952 100644 --- a/packages/next/src/build/adapter/build-complete.ts +++ b/packages/next/src/build/adapter/build-complete.ts @@ -1070,8 +1070,26 @@ export async function handleBuildComplete({ } const normalizedPage = normalizeAppPath(page) - // Skip static metadata routes - they will be output as static files - if (isStaticMetadataFile(normalizedPage)) { + // Skip static metadata routes only when they are prerendered. + // Dynamic metadata routes (e.g. robots/sitemap using connection()) + // should remain app routes in adapter outputs. + const isStaticMetadataRoute = isStaticMetadataFile(normalizedPage) + const isPrerenderedMetadataRoute = + prerenderManifest.routes[normalizedPage] || + prerenderManifest.dynamicRoutes[normalizedPage] || + config.i18n?.locales?.some((locale) => { + const localePathname = path.posix.join( + '/', + locale, + normalizedPage.slice(1) + ) + return ( + prerenderManifest.routes[localePathname] || + prerenderManifest.dynamicRoutes[localePathname] + ) + }) + + if (isStaticMetadataRoute && isPrerenderedMetadataRoute) { continue } const pageFile = path.join(appDistDir, `${page}.js`) diff --git a/packages/next/src/build/analysis/get-page-static-info.test.ts b/packages/next/src/build/analysis/get-page-static-info.test.ts index 6306bb420b30..e6a870e0cc83 100644 --- a/packages/next/src/build/analysis/get-page-static-info.test.ts +++ b/packages/next/src/build/analysis/get-page-static-info.test.ts @@ -8,7 +8,7 @@ describe('get-page-static-infos', () => { { originalSource: '/middleware/path', regexp: - '^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/middleware\\/path(\\.json)?[\\/#\\?]?$', + '^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/middleware\\/path(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$', }, ] const result = getMiddlewareMatchers(matchers, { i18n: undefined }) @@ -21,25 +21,70 @@ describe('get-page-static-infos', () => { { originalSource: '/middleware/path', regexp: - '^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/middleware\\/path(\\.json)?[\\/#\\?]?$', + '^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/middleware\\/path(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$', }, { originalSource: '/middleware/another-path', regexp: - '^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/middleware\\/another-path(\\.json)?[\\/#\\?]?$', + '^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/middleware\\/another-path(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$', }, ] const result = getMiddlewareMatchers(matchers, { i18n: undefined }) expect(result).toStrictEqual(expected) }) - it('matches /:id and /:id.json', () => { + it('matches /:id and transport variants for the same route', () => { const matchers = ['/:id'] const result = getMiddlewareMatchers(matchers, { i18n: undefined })[0] .regexp const regex = new RegExp(result) expect(regex.test('/apple')).toBe(true) expect(regex.test('/apple.json')).toBe(true) + expect(regex.test('/apple.rsc')).toBe(true) + }) + + it('matches App Router segment-prefetch routes for static matchers', () => { + const regex = new RegExp( + getMiddlewareMatchers('/dashboard', { i18n: undefined })[0].regexp + ) + + expect(regex.test('/dashboard.rsc')).toBe(true) + expect( + regex.test('/dashboard.segments/$c$children/__PAGE__.segment.rsc') + ).toBe(true) + expect( + regex.test('/settings.segments/$c$children/__PAGE__.segment.rsc') + ).toBe(false) + }) + + it('matches App Router segment-prefetch routes for nested matchers', () => { + const regex = new RegExp( + getMiddlewareMatchers('/dashboard/:path*', { + i18n: undefined, + })[0].regexp + ) + + expect( + regex.test( + '/dashboard/settings.segments/$c$children/__PAGE__.segment.rsc' + ) + ).toBe(true) + expect( + regex.test( + '/marketing/settings.segments/$c$children/__PAGE__.segment.rsc' + ) + ).toBe(false) + }) + + it('matches the root App Router segment-prefetch transport route', () => { + const regex = new RegExp( + getMiddlewareMatchers('/', { i18n: undefined })[0].regexp + ) + + expect(regex.test('/index.rsc')).toBe(true) + expect( + regex.test('/index.segments/$c$children/__PAGE__.segment.rsc') + ).toBe(true) }) }) }) diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 67b1c9387b94..e907e58345e5 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -11,11 +11,15 @@ import { SERVER_RUNTIME, MIDDLEWARE_FILENAME, PROXY_FILENAME, + RSC_SUFFIX, + RSC_SEGMENT_SUFFIX, + RSC_SEGMENTS_DIR_SUFFIX, } from '../../lib/constants' import { tryToParsePath } from '../../lib/try-to-parse-path' import { isAPIRoute } from '../../lib/is-api-route' import { isEdgeRuntime } from '../../lib/is-edge-runtime' import { RSC_MODULE_TYPES } from '../../shared/lib/constants' +import { escapeStringRegexp } from '../../shared/lib/escape-regexp' import type { RSCMeta } from '../webpack/loaders/get-module-build-info' import { PAGE_TYPES } from '../../lib/page-types' import { @@ -107,6 +111,13 @@ export interface PagesPageStaticInfo { export type PageStaticInfo = AppPageStaticInfo | PagesPageStaticInfo +const APP_ROUTE_RSC_SUFFIX_MATCHER = escapeStringRegexp(RSC_SUFFIX) +const APP_ROUTE_SEGMENT_PREFETCH_SUFFIX_MATCHER = `${escapeStringRegexp(RSC_SEGMENTS_DIR_SUFFIX)}/.+${escapeStringRegexp(RSC_SEGMENT_SUFFIX)}` +const APP_ROUTE_TRANSPORT_SUFFIX_MATCHER = `${APP_ROUTE_RSC_SUFFIX_MATCHER}|${APP_ROUTE_SEGMENT_PREFETCH_SUFFIX_MATCHER}` +const ROOT_APP_ROUTE_TRANSPORT_MATCHER = `/?index(?:${APP_ROUTE_TRANSPORT_SUFFIX_MATCHER})` +const MIDDLEWARE_DATA_SUFFIX_MATCHER = `\\.json|${APP_ROUTE_TRANSPORT_SUFFIX_MATCHER}` +const OPTIONAL_MIDDLEWARE_NEXT_DATA_PREFIX = '/:nextData(_next/data/[^/]{1,})?' + const CLIENT_MODULE_LABEL = /\/\* __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) \*\// @@ -465,11 +476,17 @@ export function getMiddlewareMatchers( }` } - source = `/:nextData(_next/data/[^/]{1,})?${source}${ + // Match transport-specific route forms that resolve to the same page. + // - Pages Router data routes: /_next/data//... + // - App Router transport routes: .rsc, ...segments/...segment.rsc + const sourceSuffix = `${ isRoot - ? `(${nextConfig.i18n ? '|\\.json|' : ''}/?index|/?index\\.json)?` - : '{(\\.json)}?' + ? `(${ + nextConfig.i18n ? '|\\.json|' : '' + }/?index|/?index\\.json|${ROOT_APP_ROUTE_TRANSPORT_MATCHER})?` + : `{(${MIDDLEWARE_DATA_SUFFIX_MATCHER})}?` }` + source = `${OPTIONAL_MIDDLEWARE_NEXT_DATA_PREFIX}${source}${sourceSuffix}` if (nextConfig.basePath) { source = `${nextConfig.basePath}${source}` diff --git a/packages/next/src/build/define-env.ts b/packages/next/src/build/define-env.ts index 8db1b0dbd8f5..fd877d8b59e6 100644 --- a/packages/next/src/build/define-env.ts +++ b/packages/next/src/build/define-env.ts @@ -46,6 +46,7 @@ const DEFINE_ENV_EXPRESSION = Symbol('DEFINE_ENV_EXPRESSION') interface DefineEnv { [key: string]: | string + | number | string[] | boolean | { [DEFINE_ENV_EXPRESSION]: string } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index a2362f70864a..df982f03f575 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -219,6 +219,7 @@ import { writeRouteTypesManifest, writeValidatorFile, } from '../server/lib/router-utils/route-types-utils' +import { writeCacheLifeTypes } from '../server/lib/router-utils/cache-life-type-utils' import { Lockfile } from './lockfile' import { buildPrefetchSegmentDataRoute, @@ -1391,6 +1392,11 @@ export default async function build( .traceAsyncFn(async () => { const routeTypesFilePath = path.join(distDir, 'types', 'routes.d.ts') const validatorFilePath = path.join(distDir, 'types', 'validator.ts') + const cacheLifeFilePath = path.join( + distDir, + 'types', + 'cache-life.d.ts' + ) await mkdir(path.dirname(routeTypesFilePath), { recursive: true }) const routeTypesManifest = await createRouteTypesManifest({ @@ -1416,6 +1422,7 @@ export default async function build( validatorFilePath, Boolean(config.experimental.strictRouteTypes) ) + writeCacheLifeTypes(config.cacheLife, cacheLifeFilePath) }) // Turbopack already handles conflicting app and page routes. @@ -2082,6 +2089,7 @@ export default async function build( config.experimental.partialFallbacks === true, cacheLifeProfiles: config.cacheLife, buildId, + deploymentId: config.deploymentId, clientAssetToken: config.experimental.immutableAssetToken || config.deploymentId, sriEnabled, @@ -2311,6 +2319,7 @@ export default async function build( config.experimental.partialFallbacks === true, cacheLifeProfiles: config.cacheLife, buildId, + deploymentId: config.deploymentId, clientAssetToken: config.experimental.immutableAssetToken || config.deploymentId, diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index 1c8139e1fc30..b5143678235b 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -797,6 +797,7 @@ export async function buildAppStaticPaths({ isRoutePPREnabled = false, partialFallbacksEnabled = false, buildId, + deploymentId, rootParamKeys, }: { dir: string @@ -820,6 +821,7 @@ export async function buildAppStaticPaths({ isRoutePPREnabled: boolean partialFallbacksEnabled?: boolean buildId: string + deploymentId: string rootParamKeys: readonly string[] }): Promise { if ( @@ -871,6 +873,7 @@ export async function buildAppStaticPaths({ onAfterTaskError: afterRunner.context.onTaskError, }, buildId, + deploymentId, previouslyRevalidatedTags: [], }) diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index e738267b160e..2e3083ff533d 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -9,31 +9,41 @@ import { import { RouteKind } from '../../server/route-kind' with { 'turbopack-transition': 'next-server-utility' } -import { getRevalidateReason } from '../../server/instrumentation/utils' -import { getTracer, SpanKind, type Span } from '../../server/lib/trace/tracer' +import { getRevalidateReason } from '../../server/instrumentation/utils' with { 'turbopack-transition': 'next-server-utility' } +import { + getTracer, + SpanKind, + type Span, +} from '../../server/lib/trace/tracer' with { 'turbopack-transition': 'next-server-utility' } import type { RequestMeta } from '../../server/request-meta' import { addRequestMeta, getRequestMeta, setRequestMeta, -} from '../../server/request-meta' -import { BaseServerSpan } from '../../server/lib/trace/constants' -import { interopDefault } from '../../server/app-render/interop-default' -import { stripFlightHeaders } from '../../server/app-render/strip-flight-headers' -import { NodeNextRequest, NodeNextResponse } from '../../server/base-http/node' -import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr' +} from '../../server/request-meta' with { 'turbopack-transition': 'next-server-utility' } +import { BaseServerSpan } from '../../server/lib/trace/constants' with { 'turbopack-transition': 'next-server-utility' } +import { interopDefault } from '../../server/app-render/interop-default' with { 'turbopack-transition': 'next-server-utility' } +import { stripFlightHeaders } from '../../server/app-render/strip-flight-headers' with { 'turbopack-transition': 'next-server-utility' } +import { + NodeNextRequest, + NodeNextResponse, +} from '../../server/base-http/node' with { 'turbopack-transition': 'next-server-utility' } +import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr' with { 'turbopack-transition': 'next-server-utility' } +import { isRSCRequestHeader } from '../../server/lib/is-rsc-request' with { 'turbopack-transition': 'next-server-utility' } import { getFallbackRouteParams, + getPlaceholderFallbackRouteParams, + buildDynamicSegmentPlaceholder, createOpaqueFallbackRouteParams, type OpaqueFallbackRouteParams, -} from '../../server/request/fallback-params' -import { setManifestsSingleton } from '../../server/app-render/manifests-singleton' +} from '../../server/request/fallback-params' with { 'turbopack-transition': 'next-server-utility' } +import { setManifestsSingleton } from '../../server/app-render/manifests-singleton' with { 'turbopack-transition': 'next-server-utility' } import { isHtmlBotRequest, shouldServeStreamingMetadata, -} from '../../server/lib/streaming-metadata' -import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' -import { getIsPossibleServerAction } from '../../server/lib/server-action-request-meta' +} from '../../server/lib/streaming-metadata' with { 'turbopack-transition': 'next-server-utility' } +import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' with { 'turbopack-transition': 'next-server-utility' } +import { getIsPossibleServerAction } from '../../server/lib/server-action-request-meta' with { 'turbopack-transition': 'next-server-utility' } import { RSC_HEADER, NEXT_ROUTER_PREFETCH_HEADER, @@ -42,8 +52,11 @@ import { NEXT_IS_PRERENDER_HEADER, NEXT_DID_POSTPONE_HEADER, RSC_CONTENT_TYPE_HEADER, -} from '../../client/components/app-router-headers' -import { getBotType, isBot } from '../../shared/lib/router/utils/is-bot' +} from '../../client/components/app-router-headers' with { 'turbopack-transition': 'next-server-utility' } +import { + getBotType, + isBot, +} from '../../shared/lib/router/utils/is-bot' with { 'turbopack-transition': 'next-server-utility' } import { CachedRouteKind, IncrementalCacheKind, @@ -51,9 +64,12 @@ import { type CachedPageValue, type ResponseCacheEntry, type ResponseGenerator, -} from '../../server/response-cache' -import { FallbackMode, parseFallbackField } from '../../lib/fallback' -import RenderResult from '../../server/render-result' +} from '../../server/response-cache' with { 'turbopack-transition': 'next-server-utility' } +import { + FallbackMode, + parseFallbackField, +} from '../../lib/fallback' with { 'turbopack-transition': 'next-server-utility' } +import RenderResult from '../../server/render-result' with { 'turbopack-transition': 'next-server-utility' } import { CACHE_ONE_YEAR_SECONDS, HTML_CONTENT_TYPE_HEADER, @@ -61,18 +77,19 @@ import { NEXT_NAV_DEPLOYMENT_ID_HEADER, NEXT_RESUME_HEADER, NEXT_RESUME_STATE_LENGTH_HEADER, -} from '../../lib/constants' +} from '../../lib/constants' with { 'turbopack-transition': 'next-server-utility' } import type { CacheControl } from '../../server/lib/cache-control' -import { ENCODED_TAGS } from '../../server/stream-utils/encoded-tags' -import { createInstantTestScriptInsertionTransformStream } from '../../server/stream-utils/node-web-streams-helper' -import { sendRenderResult } from '../../server/send-payload' -import { NoFallbackError } from '../../shared/lib/no-fallback-error.external' -import { parseMaxPostponedStateSize } from '../../shared/lib/size-limit' +import { ENCODED_TAGS } from '../../server/stream-utils/encoded-tags' with { 'turbopack-transition': 'next-server-utility' } +import { createInstantTestScriptInsertionTransformStream } from '../../server/stream-utils/node-web-streams-helper' with { 'turbopack-transition': 'next-server-utility' } +import { sendRenderResult } from '../../server/send-payload' with { 'turbopack-transition': 'next-server-utility' } +import { NoFallbackError } from '../../shared/lib/no-fallback-error.external' with { 'turbopack-transition': 'next-server-utility' } +import { parseMaxPostponedStateSize } from '../../shared/lib/size-limit' with { 'turbopack-transition': 'next-server-utility' } import { getMaxPostponedStateSize, getPostponedStateExceededErrorMessage, readBodyWithSizeLimit, -} from '../../server/lib/postponed-request-body' +} from '../../server/lib/postponed-request-body' with { 'turbopack-transition': 'next-server-utility' } +import { parseUrl } from '../../lib/url' // These are injected by the loader afterwards. @@ -98,14 +115,11 @@ export const __next_app__ = { } import * as entryBase from '../../server/app-render/entry-base' with { 'turbopack-transition': 'next-server-utility' } -import { RedirectStatusCode } from '../../client/components/redirect-status-code' -import { InvariantError } from '../../shared/lib/invariant-error' -import { scheduleOnNextTick } from '../../lib/scheduler' -import { isInterceptionRouteAppPath } from '../../shared/lib/router/utils/interception-routes' -import { - getParamProperties, - getSegmentParam, -} from '../../shared/lib/router/utils/get-segment-param' +import { RedirectStatusCode } from '../../client/components/redirect-status-code' with { 'turbopack-transition': 'next-server-utility' } +import { InvariantError } from '../../shared/lib/invariant-error' with { 'turbopack-transition': 'next-server-utility' } +import { scheduleOnNextTick } from '../../lib/scheduler' with { 'turbopack-transition': 'next-server-utility' } +import { isInterceptionRouteAppPath } from '../../shared/lib/router/utils/interception-routes' with { 'turbopack-transition': 'next-server-utility' } +import { getSegmentParam } from '../../shared/lib/router/utils/get-segment-param' with { 'turbopack-transition': 'next-server-utility' } export * from '../../server/app-render/entry-base' with { 'turbopack-transition': 'next-server-utility' } @@ -127,22 +141,6 @@ export const routeModule = new AppPageRouteModule({ relativeProjectDir: process.env.__NEXT_RELATIVE_PROJECT_DIR || '', }) -function buildDynamicSegmentPlaceholder( - param: Pick -): string { - const { repeat, optional } = getParamProperties(param.paramType) - - if (optional) { - return `[[...${param.paramName}]]` - } - - if (repeat) { - return `[...${param.paramName}]` - } - - return `[${param.paramName}]` -} - /** * Builds the cache key for the most complete prerenderable shell we can derive * from the shell that matched this request. Only params that can still be @@ -295,7 +293,8 @@ export async function handler( // NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later const isRSCRequest = - getRequestMeta(req, 'isRSCRequest') ?? Boolean(req.headers[RSC_HEADER]) + getRequestMeta(req, 'isRSCRequest') ?? + isRSCRequestHeader(req.headers[RSC_HEADER]) const isPossibleServerAction = getIsPossibleServerAction(req) @@ -427,7 +426,7 @@ export async function handler( const isInstantNavigationTest = exposeTestingApi && (req.headers[NEXT_INSTANT_PREFETCH_HEADER] === '1' || - (req.headers[RSC_HEADER] === undefined && + (!isRSCRequestHeader(req.headers[RSC_HEADER]) && typeof req.headers.cookie === 'string' && req.headers.cookie.includes(NEXT_INSTANT_TEST_COOKIE + '='))) @@ -607,7 +606,14 @@ export async function handler( if ( !staticPathKey && (routeModule.isDev || - (isSSG && pageIsDynamic && prerenderInfo?.fallbackRouteParams)) + (isSSG && + pageIsDynamic && + prerenderInfo?.fallbackRouteParams && + // Server action requests must not get a staticPathKey, otherwise they + // enter the fallback rendering block below and return the cached HTML + // shell with the action result appended, instead of responding with + // just the RSC action result. + !isPossibleServerAction)) ) { staticPathKey = resolvedPathname } @@ -978,214 +984,317 @@ export async function handler( const isProduction = routeModule.isDev === false const didRespond = hasResolved || res.writableEnded - // skip on-demand revalidate if cache is not present and - // revalidate-if-generated is set - if ( - isOnDemandRevalidate && - revalidateOnlyGenerated && - !previousIncrementalCacheEntry && - !isMinimalMode - ) { - if (routerServerContext?.render404) { - await routerServerContext.render404(req, res) - } else { - res.statusCode = 404 - res.end('This page could not be found') + try { + // skip on-demand revalidate if cache is not present and + // revalidate-if-generated is set + if ( + isOnDemandRevalidate && + revalidateOnlyGenerated && + !previousIncrementalCacheEntry && + !isMinimalMode + ) { + if (routerServerContext?.render404) { + await routerServerContext.render404(req, res) + } else { + res.statusCode = 404 + res.end('This page could not be found') + } + return null } - return null - } - - let fallbackMode: FallbackMode | undefined - if (prerenderInfo) { - fallbackMode = parseFallbackField(prerenderInfo.fallback) - } + let fallbackMode: FallbackMode | undefined - if ( - nextConfig.experimental.partialFallbacks === true && - prerenderInfo?.fallback === null && - !hasUnresolvedRootFallbackParams && - remainingPrerenderableParams.length > 0 - ) { - // Generic source shells without unresolved root params don't have a - // concrete fallback file of their own, so they're marked as blocking. - // When we can complete the shell into a more specific - // prerendered shell for this request, treat it like a prerender - // fallback so we can serve that shell instead of blocking on the full - // route. Root-param shells stay blocking, since unknown root branches - // should not inherit a shell from another generated branch. - fallbackMode = FallbackMode.PRERENDER - } + if (prerenderInfo) { + fallbackMode = parseFallbackField(prerenderInfo.fallback) + } - // When serving a HTML bot request, we want to serve a blocking render and - // not the prerendered page. This ensures that the correct content is served - // to the bot in the head. - if (fallbackMode === FallbackMode.PRERENDER && isBot(userAgent)) { - if (!isRoutePPREnabled || isHtmlBot) { - fallbackMode = FallbackMode.BLOCKING_STATIC_RENDER + if ( + nextConfig.experimental.partialFallbacks === true && + prerenderInfo?.fallback === null && + !hasUnresolvedRootFallbackParams && + remainingPrerenderableParams.length > 0 + ) { + // Generic source shells without unresolved root params don't have a + // concrete fallback file of their own, so they're marked as blocking. + // When we can complete the shell into a more specific + // prerendered shell for this request, treat it like a prerender + // fallback so we can serve that shell instead of blocking on the full + // route. Root-param shells stay blocking, since unknown root branches + // should not inherit a shell from another generated branch. + fallbackMode = FallbackMode.PRERENDER } - } - if (previousIncrementalCacheEntry?.isStale === -1) { - isOnDemandRevalidate = true - } + // When serving a HTML bot request, we want to serve a blocking render and + // not the prerendered page. This ensures that the correct content is served + // to the bot in the head. + if (fallbackMode === FallbackMode.PRERENDER && isBot(userAgent)) { + if (!isRoutePPREnabled || isHtmlBot) { + fallbackMode = FallbackMode.BLOCKING_STATIC_RENDER + } + } - // TODO: adapt for PPR - // only allow on-demand revalidate for fallback: true/blocking - // or for prerendered fallback: false paths - if ( - isOnDemandRevalidate && - (fallbackMode !== FallbackMode.NOT_FOUND || - previousIncrementalCacheEntry) - ) { - fallbackMode = FallbackMode.BLOCKING_STATIC_RENDER - } + if (previousIncrementalCacheEntry?.isStale === -1) { + isOnDemandRevalidate = true + } - if ( - !isMinimalMode && - fallbackMode !== FallbackMode.BLOCKING_STATIC_RENDER && - staticPathKey && - !didRespond && - !isDraftMode && - pageIsDynamic && - (isProduction || !isPrerendered) - ) { - // if the page has dynamicParams: false and this pathname wasn't - // prerendered trigger the no fallback handling + // TODO: adapt for PPR + // only allow on-demand revalidate for fallback: true/blocking + // or for prerendered fallback: false paths if ( - // In development, fall through to render to handle missing - // getStaticPaths. - (isProduction || prerenderInfo) && - // When fallback isn't present, abort this render so we 404 - fallbackMode === FallbackMode.NOT_FOUND + isOnDemandRevalidate && + (fallbackMode !== FallbackMode.NOT_FOUND || + previousIncrementalCacheEntry) ) { - if (nextConfig.adapterPath) { - return await render404() - } - throw new NoFallbackError() + fallbackMode = FallbackMode.BLOCKING_STATIC_RENDER } - // When cacheComponents is enabled, we can use the fallback - // response if the request is not a dynamic RSC request because the - // RSC data when this feature flag is enabled does not contain any - // param references. Without this feature flag enabled, the RSC data - // contains param references, and therefore we can't use the fallback. if ( - isRoutePPREnabled && - (nextConfig.cacheComponents ? !isDynamicRSCRequest : !isRSCRequest) + !isMinimalMode && + fallbackMode !== FallbackMode.BLOCKING_STATIC_RENDER && + staticPathKey && + !didRespond && + !isDraftMode && + pageIsDynamic && + (isProduction || !isPrerendered) ) { - const cacheKey = - isProduction && typeof prerenderInfo?.fallback === 'string' - ? prerenderInfo.fallback - : normalizedSrcPage - - const fallbackRouteParams = - // In production or when debugging the static shell (e.g. instant - // navigation testing), use the prerender manifest's fallback - // route params which correctly identifies which params are - // unknown. Note: in dev, this block is only entered for - // non-prerendered URLs (guarded by the outer condition). - (isProduction || isDebugStaticShell) && - prerenderInfo?.fallbackRouteParams - ? createOpaqueFallbackRouteParams( - prerenderInfo.fallbackRouteParams - ) - : // When debugging the fallback shell, treat all params as - // fallback (simulating the worst-case shell). - isDebugFallbackShell - ? getFallbackRouteParams(normalizedSrcPage, routeModule) - : null + // if the page has dynamicParams: false and this pathname wasn't + // prerendered trigger the no fallback handling + if ( + // In development, fall through to render to handle missing + // getStaticPaths. + (isProduction || prerenderInfo) && + // When fallback isn't present, abort this render so we 404 + fallbackMode === FallbackMode.NOT_FOUND + ) { + if (nextConfig.adapterPath) { + return await render404() + } + throw new NoFallbackError() + } + + // When cacheComponents is enabled, we can use the fallback + // response if the request is not a dynamic RSC request because the + // RSC data when this feature flag is enabled does not contain any + // param references. Without this feature flag enabled, the RSC data + // contains param references, and therefore we can't use the fallback. + if ( + isRoutePPREnabled && + (nextConfig.cacheComponents ? !isDynamicRSCRequest : !isRSCRequest) + ) { + const cacheKey = + isProduction && typeof prerenderInfo?.fallback === 'string' + ? prerenderInfo.fallback + : normalizedSrcPage + + const fallbackRouteParams = + // In production or when debugging the static shell (e.g. instant + // navigation testing), use the prerender manifest's fallback + // route params which correctly identifies which params are + // unknown. Note: in dev, this block is only entered for + // non-prerendered URLs (guarded by the outer condition). + (isProduction || isDebugStaticShell) && + prerenderInfo?.fallbackRouteParams + ? createOpaqueFallbackRouteParams( + prerenderInfo.fallbackRouteParams + ) + : // When debugging the fallback shell, treat all params as + // fallback (simulating the worst-case shell). + isDebugFallbackShell + ? getFallbackRouteParams(normalizedSrcPage, routeModule) + : null + + // When rendering a debug static shell, override the fallback + // params on the request so that the staged rendering correctly + // defers params that are not statically known. + if (isDebugStaticShell && fallbackRouteParams) { + addRequestMeta(req, 'fallbackParams', fallbackRouteParams) + } - // When rendering a debug static shell, override the fallback - // params on the request so that the staged rendering correctly - // defers params that are not statically known. - if (isDebugStaticShell && fallbackRouteParams) { - addRequestMeta(req, 'fallbackParams', fallbackRouteParams) + // We use the response cache here to handle the revalidation and + // management of the fallback shell. + const fallbackResponse = await routeModule.handleResponse({ + cacheKey, + req, + nextConfig, + routeKind: RouteKind.APP_PAGE, + isFallback: true, + prerenderManifest, + isRoutePPREnabled, + responseGenerator: async () => + doRender({ + span, + // We pass `undefined` as rendering a fallback isn't resumed + // here. + postponed: undefined, + // Always serve the shell that matched this request + // immediately. If there are still prerenderable params left, + // the background path below will complete the shell into a + // more specific cache entry for later requests. + fallbackRouteParams, + forceStaticRender: true, + }), + waitUntil: ctx.waitUntil, + isMinimalMode, + }) + + // If the fallback response was set to null, then we should return null. + if (fallbackResponse === null) return null + + // Otherwise, if we did get a fallback response, we should return it. + if (fallbackResponse) { + if ( + !isMinimalMode && + isRoutePPREnabled && + // Match the build-time contract: only fallback shells that can + // still be completed with prerenderable params should upgrade. + remainingPrerenderableParams.length > 0 && + nextConfig.experimental.partialFallbacks === true && + ssgCacheKey && + incrementalCache && + !isOnDemandRevalidate && + !isDebugFallbackShell && + // The testing API relies on deterministic shell behavior, so + // don't upgrade fallback shells in the background when it's + // exposed. + !exposeTestingApi && + // Instant Navigation Testing API requests intentionally keep + // the route in shell mode; don't upgrade these in background. + !isInstantNavigationTest && + // Avoid background revalidate during prefetches; this can trigger + // static prerender errors that surface as 500s for the prefetch + // request itself. + !isPrefetchRSCRequest + ) { + scheduleOnNextTick(async () => { + const responseCache = routeModule.getResponseCache(req) + + try { + // Only the params that were just specialized should be + // removed from the fallback render. Any remaining fallback + // params stay deferred so the revalidated result is a more + // specific shell (e.g. `/prefix/c/[two]`), not a fully + // concrete route (`/prefix/c/foo`). + await responseCache.revalidate( + ssgCacheKey, + incrementalCache, + isRoutePPREnabled, + false, + (c) => { + return doRender({ + span: c.span, + postponed: undefined, + fallbackRouteParams: + remainingFallbackRouteParams.length > 0 + ? createOpaqueFallbackRouteParams( + remainingFallbackRouteParams + ) + : null, + forceStaticRender: true, + }) + }, + // We don't have a prior entry for this param-specific shell. + null, + hasResolved, + ctx.waitUntil + ) + } catch (err) { + console.error( + 'Error revalidating the page in the background', + err + ) + } + }) + } + + // Remove the cache control from the response to prevent it from being + // used in the surrounding cache. + delete fallbackResponse.cacheControl + + return fallbackResponse + } } + } - // We use the response cache here to handle the revalidation and - // management of the fallback shell. - const fallbackResponse = await routeModule.handleResponse({ - cacheKey, - req, - nextConfig, - routeKind: RouteKind.APP_PAGE, - isFallback: true, - prerenderManifest, - isRoutePPREnabled, - responseGenerator: async () => - doRender({ - span, - // We pass `undefined` as rendering a fallback isn't resumed - // here. - postponed: undefined, - // Always serve the shell that matched this request - // immediately. If there are still prerenderable params left, - // the background path below will complete the shell into a - // more specific cache entry for later requests. - fallbackRouteParams, - forceStaticRender: true, - }), - waitUntil: ctx.waitUntil, - isMinimalMode, - }) + // Only requests that aren't revalidating can be resumed. If we have the + // minimal postponed data, then we should resume the render with it. + let postponed = + !isOnDemandRevalidate && !isRevalidating && minimalPostponed + ? minimalPostponed + : undefined - // If the fallback response was set to null, then we should return null. - if (fallbackResponse === null) return null + if ( + // If this is a dynamic RSC request or a server action request, we should + // use the postponed data from the static render (if available). This + // ensures that we can utilize the resume data cache (RDC) from the static + // render to ensure that the data is consistent between the static and + // dynamic renders (for navigations) or when re-rendering after a server + // action. + // Only enable RDC for Navigations if the feature is enabled. + supportsRDCForNavigations && + process.env.NEXT_RUNTIME !== 'edge' && + !isMinimalMode && + incrementalCache && + // Include both dynamic RSC requests (navigations) and server actions + (isDynamicRSCRequest || isPossibleServerAction) && + // We don't typically trigger an on-demand revalidation for dynamic RSC + // requests, as we're typically revalidating the page in the background + // instead. However, if the cache entry is stale, we should trigger a + // background revalidation on dynamic RSC requests. This prevents us + // from entering an infinite loop of revalidations. + !forceStaticRender + ) { + const incrementalCacheEntry = await incrementalCache.get( + resolvedPathname, + { + kind: IncrementalCacheKind.APP_PAGE, + isRoutePPREnabled: true, + isFallback: false, + } + ) - // Otherwise, if we did get a fallback response, we should return it. - if (fallbackResponse) { + // If the cache entry is found, we should use the postponed data from + // the cache. + if ( + incrementalCacheEntry && + incrementalCacheEntry.value && + incrementalCacheEntry.value.kind === CachedRouteKind.APP_PAGE + ) { + // CRITICAL: we're assigning the postponed data from the cache entry + // here as we're using the RDC to resume the render. + postponed = incrementalCacheEntry.value.postponed + + // If the cache entry is stale, we should trigger a background + // revalidation so that subsequent requests will get a fresh response. if ( - !isMinimalMode && - isRoutePPREnabled && - // Match the build-time contract: only fallback shells that can - // still be completed with prerenderable params should upgrade. - remainingPrerenderableParams.length > 0 && - nextConfig.experimental.partialFallbacks === true && - ssgCacheKey && - incrementalCache && - !isOnDemandRevalidate && - !isDebugFallbackShell && - // The testing API relies on deterministic shell behavior, so - // don't upgrade fallback shells in the background when it's - // exposed. - !exposeTestingApi && - // Instant Navigation Testing API requests intentionally keep - // the route in shell mode; don't upgrade these in background. - !isInstantNavigationTest && - // Avoid background revalidate during prefetches; this can trigger - // static prerender errors that surface as 500s for the prefetch - // request itself. - !isPrefetchRSCRequest + incrementalCacheEntry && + // We want to trigger this flow if the cache entry is stale and if + // the requested revalidation flow is either foreground or + // background. + (incrementalCacheEntry.isStale === -1 || + incrementalCacheEntry.isStale === true) ) { + // We want to schedule this on the next tick to ensure that the + // render is not blocked on it. scheduleOnNextTick(async () => { const responseCache = routeModule.getResponseCache(req) try { - // Only the params that were just specialized should be - // removed from the fallback render. Any remaining fallback - // params stay deferred so the revalidated result is a more - // specific shell (e.g. `/prefix/c/[two]`), not a fully - // concrete route (`/prefix/c/foo`). await responseCache.revalidate( - ssgCacheKey, + resolvedPathname, incrementalCache, isRoutePPREnabled, false, - (c) => { - return doRender({ - span: c.span, - postponed: undefined, - fallbackRouteParams: - remainingFallbackRouteParams.length > 0 - ? createOpaqueFallbackRouteParams( - remainingFallbackRouteParams - ) - : null, + (c) => + responseGenerator({ + ...c, + // CRITICAL: we need to set this to true as we're + // revalidating in the background and typically this dynamic + // RSC request is not treated as static. forceStaticRender: true, - }) - }, - // We don't have a prior entry for this param-specific shell. + }), + // CRITICAL: we need to pass null here because passing the + // previous cache entry here (which is stale) will switch on + // isOnDemandRevalidate and break the prerendering. null, hasResolved, ctx.waitUntil @@ -1198,168 +1307,137 @@ export async function handler( } }) } + } + } - // Remove the cache control from the response to prevent it from being - // used in the surrounding cache. - delete fallbackResponse.cacheControl - - return fallbackResponse + // When we're in minimal mode, if we're trying to debug the static shell, + // we should just return nothing instead of resuming the dynamic render. + if ( + (isDebugStaticShell || isDebugDynamicAccesses) && + typeof postponed !== 'undefined' + ) { + return { + cacheControl: { revalidate: 1, expire: undefined }, + value: { + kind: CachedRouteKind.PAGES, + html: RenderResult.EMPTY, + pageData: {}, + headers: undefined, + status: undefined, + } satisfies CachedPageValue, } } - } - // Only requests that aren't revalidating can be resumed. If we have the - // minimal postponed data, then we should resume the render with it. - let postponed = - !isOnDemandRevalidate && !isRevalidating && minimalPostponed - ? minimalPostponed - : undefined + const placeholderFallbackRouteParams = + // When a request carries dynamic placeholder values (e.g. "[slug]"), + // defer only the unresolved subset instead of forcing all fallback + // params to suspend. + !routeModule.isDev && + pageIsDynamic && + prerenderInfo?.fallbackRouteParams + ? getPlaceholderFallbackRouteParams( + params as + | Record + | undefined, + prerenderInfo.fallbackRouteParams + ) + : null - // If this is a dynamic RSC request or a server action request, we should - // use the postponed data from the static render (if available). This - // ensures that we can utilize the resume data cache (RDC) from the static - // render to ensure that the data is consistent between the static and - // dynamic renders (for navigations) or when re-rendering after a server - // action. - if ( - // Only enable RDC for Navigations if the feature is enabled. - supportsRDCForNavigations && - process.env.NEXT_RUNTIME !== 'edge' && - !isMinimalMode && - incrementalCache && - // Include both dynamic RSC requests (navigations) and server actions - (isDynamicRSCRequest || isPossibleServerAction) && - // We don't typically trigger an on-demand revalidation for dynamic RSC - // requests, as we're typically revalidating the page in the background - // instead. However, if the cache entry is stale, we should trigger a - // background revalidation on dynamic RSC requests. This prevents us - // from entering an infinite loop of revalidations. - !forceStaticRender - ) { - const incrementalCacheEntry = await incrementalCache.get( - resolvedPathname, - { - kind: IncrementalCacheKind.APP_PAGE, - isRoutePPREnabled: true, - isFallback: false, + const fallbackRouteParamsForRender = + placeholderFallbackRouteParams && + placeholderFallbackRouteParams.length > 0 + ? placeholderFallbackRouteParams + : prerenderInfo?.fallbackRouteParams + + const hasPlaceholderFallbackRouteParams = + placeholderFallbackRouteParams != null && + placeholderFallbackRouteParams.length > 0 + + // When route-module.ts resolved partial nxtP* params during + // background revalidation, filter fallbackRouteParams to only the + // params that are still unresolved. This lets doRender produce an + // intermediate PPR shell that suspends only for those params. + let effectiveFallbackRouteParams: FallbackRouteParam[] | null = null + if (nextConfig.cacheComponents && prerenderInfo?.fallbackRouteParams) { + const resolvedKeys = getRequestMeta(req, 'resolvedRouteParamKeys') + if (resolvedKeys && resolvedKeys.size > 0) { + effectiveFallbackRouteParams = + prerenderInfo.fallbackRouteParams.filter( + (param) => !resolvedKeys.has(param.paramName) + ) } - ) + } + const fallbackRouteParams = + // In production or when debugging the static shell for a + // non-prerendered URL, use the prerender manifest's fallback route + // params which correctly identifies which params are unknown. + ((isProduction && getRequestMeta(req, 'renderFallbackShell')) || + hasPlaceholderFallbackRouteParams || + (isDebugStaticShell && !isPrerendered)) && + fallbackRouteParamsForRender + ? createOpaqueFallbackRouteParams(fallbackRouteParamsForRender) + : // For intermediate shells where some params are resolved and + // others still have placeholders, use the filtered subset so the + // prerender suspends only for the unresolved params. + effectiveFallbackRouteParams && + effectiveFallbackRouteParams.length > 0 && + effectiveFallbackRouteParams.length < + (prerenderInfo?.fallbackRouteParams?.length ?? 0) + ? createOpaqueFallbackRouteParams(effectiveFallbackRouteParams) + : isDebugFallbackShell + ? getFallbackRouteParams(normalizedSrcPage, routeModule) + : null - // If the cache entry is found, we should use the postponed data from - // the cache. + // For staged dynamic rendering (Cached Navigations) and debug static + // shell rendering, pass the fallback params via request meta so the + // RequestStore knows which params to defer. We don't pass them as + // fallbackRouteParams because that would replace actual param values + // with opaque placeholders during segment resolution. if ( - incrementalCacheEntry && - incrementalCacheEntry.value && - incrementalCacheEntry.value.kind === CachedRouteKind.APP_PAGE + (isProduction || isDebugStaticShell) && + nextConfig.cacheComponents && + !isPrerendered && + prerenderInfo?.fallbackRouteParams ) { - // CRITICAL: we're assigning the postponed data from the cache entry - // here as we're using the RDC to resume the render. - postponed = incrementalCacheEntry.value.postponed + const fallbackParams = createOpaqueFallbackRouteParams( + fallbackRouteParamsForRender ?? prerenderInfo.fallbackRouteParams + ) - // If the cache entry is stale, we should trigger a background - // revalidation so that subsequent requests will get a fresh response. - if ( - incrementalCacheEntry && - // We want to trigger this flow if the cache entry is stale and if - // the requested revalidation flow is either foreground or - // background. - (incrementalCacheEntry.isStale === -1 || - incrementalCacheEntry.isStale === true) - ) { - // We want to schedule this on the next tick to ensure that the - // render is not blocked on it. - scheduleOnNextTick(async () => { - const responseCache = routeModule.getResponseCache(req) - - try { - await responseCache.revalidate( - resolvedPathname, - incrementalCache, - isRoutePPREnabled, - false, - (c) => - responseGenerator({ - ...c, - // CRITICAL: we need to set this to true as we're - // revalidating in the background and typically this dynamic - // RSC request is not treated as static. - forceStaticRender: true, - }), - // CRITICAL: we need to pass null here because passing the - // previous cache entry here (which is stale) will switch on - // isOnDemandRevalidate and break the prerendering. - null, - hasResolved, - ctx.waitUntil - ) - } catch (err) { - console.error( - 'Error revalidating the page in the background', - err - ) - } - }) + if (fallbackParams) { + addRequestMeta(req, 'fallbackParams', fallbackParams) } } - } - - // When we're in minimal mode, if we're trying to debug the static shell, - // we should just return nothing instead of resuming the dynamic render. - if ( - (isDebugStaticShell || isDebugDynamicAccesses) && - typeof postponed !== 'undefined' - ) { - return { - cacheControl: { revalidate: 1, expire: undefined }, - value: { - kind: CachedRouteKind.PAGES, - html: RenderResult.EMPTY, - pageData: {}, - headers: undefined, - status: undefined, - } satisfies CachedPageValue, - } - } - - const fallbackRouteParams = - // In production or when debugging the static shell for a - // non-prerendered URL, use the prerender manifest's fallback route - // params which correctly identifies which params are unknown. - ((isProduction && getRequestMeta(req, 'renderFallbackShell')) || - (isDebugStaticShell && !isPrerendered)) && - prerenderInfo?.fallbackRouteParams - ? createOpaqueFallbackRouteParams(prerenderInfo.fallbackRouteParams) - : isDebugFallbackShell - ? getFallbackRouteParams(normalizedSrcPage, routeModule) - : null - - // For staged dynamic rendering (Cached Navigations) and debug static - // shell rendering, pass the fallback params via request meta so the - // RequestStore knows which params to defer. We don't pass them as - // fallbackRouteParams because that would replace actual param values - // with opaque placeholders during segment resolution. - if ( - (isProduction || isDebugStaticShell) && - nextConfig.cacheComponents && - !isPrerendered && - prerenderInfo?.fallbackRouteParams - ) { - const fallbackParams = createOpaqueFallbackRouteParams( - prerenderInfo.fallbackRouteParams - ) - if (fallbackParams) { - addRequestMeta(req, 'fallbackParams', fallbackParams) + // Perform the render. + return doRender({ + span, + postponed, + fallbackRouteParams, + forceStaticRender, + }) + } catch (err) { + // if this is a background revalidate we need to report + // the request error here as it won't be bubbled + if (previousIncrementalCacheEntry?.isStale) { + const silenceLog = false + await routeModule.onRequestError( + req, + err, + { + routerKind: 'App Router', + routePath: srcPage, + routeType: 'render', + revalidateReason: getRevalidateReason({ + isStaticGeneration: isSSG, + isOnDemandRevalidate, + }), + }, + silenceLog, + routerServerContext + ) } + throw err } - - // Perform the render. - return doRender({ - span, - postponed, - fallbackRouteParams, - forceStaticRender, - }) } const handleResponse = async (span?: Span): Promise => { @@ -1572,9 +1650,15 @@ export async function handler( getRequestMeta(req, 'onCacheEntry')) : getRequestMeta(req, 'onCacheEntry') if (onCacheEntry) { + const rawCacheEntryUrl = getRequestMeta(req, 'initURL') ?? req.url + const cacheEntryUrl = rawCacheEntryUrl + ? (parseUrl(rawCacheEntryUrl)?.pathname ?? rawCacheEntryUrl) + : undefined + const finished = await onCacheEntry(cacheEntry, { - url: getRequestMeta(req, 'initURL') ?? req.url, + url: cacheEntryUrl, }) + if (finished) return null } @@ -1695,7 +1779,9 @@ export async function handler( const instantTestRequestId = routeModule.isDev === true ? crypto.randomUUID() : null body.pipeThrough( - createInstantTestScriptInsertionTransformStream(instantTestRequestId) + await createInstantTestScriptInsertionTransformStream( + instantTestRequestId + ) ) return sendRenderResult({ req, diff --git a/packages/next/src/build/templates/app-route.ts b/packages/next/src/build/templates/app-route.ts index 50e8b04f5255..784a7009217b 100644 --- a/packages/next/src/build/templates/app-route.ts +++ b/packages/next/src/build/templates/app-route.ts @@ -2,6 +2,7 @@ import { AppRouteRouteModule, type AppRouteRouteHandlerContext, type AppRouteRouteModuleOptions, + type AppRouteUserlandModule, } from '../../server/route-modules/app-route/module.compiled' import { RouteKind } from '../../server/route-kind' import { patchFetch as _patchFetch } from '../../server/lib/patch-fetch' @@ -35,7 +36,6 @@ import { type ResponseCacheEntry, type ResponseGenerator, } from '../../server/response-cache' - import * as userland from 'VAR_USERLAND' // These are injected by the loader afterwards. This is injected as a variable @@ -59,7 +59,17 @@ const routeModule = new AppRouteRouteModule({ relativeProjectDir: process.env.__NEXT_RELATIVE_PROJECT_DIR || '', resolvedPagePath: 'VAR_RESOLVED_PAGE_PATH', nextConfigOutput, - userland, + // The static import is used for initialization (methods, dynamic, etc.). + userland: userland as AppRouteUserlandModule, + // In Turbopack dev mode, also provide a getter that calls require() on every + // request. This re-reads from devModuleCache so HMR updates are picked up, + // and the async wrapper unwraps async-module Promises (ESM-only + // serverExternalPackages) automatically. + ...(process.env.TURBOPACK && process.env.__NEXT_DEV_SERVER + ? { + getUserland: () => import('VAR_USERLAND'), + } + : {}), }) // Pull out the exports that we need to expose from the module. This should @@ -124,6 +134,7 @@ export async function handler( const { buildId, + deploymentId, params, nextConfig, parsedUrl, @@ -252,6 +263,7 @@ export async function handler( }, sharedContext: { buildId, + deploymentId, }, } const nodeNextReq = new NodeNextRequest(req) diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 359e22388813..3b902e2e20b4 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -669,6 +669,7 @@ export async function isPageStatic({ pprConfig, partialFallbacksEnabled, buildId, + deploymentId, clientAssetToken, sriEnabled, }: { @@ -697,6 +698,7 @@ export async function isPageStatic({ pprConfig: ExperimentalPPRConfig | undefined partialFallbacksEnabled: boolean buildId: string + deploymentId: string clientAssetToken: string sriEnabled: boolean }): Promise { @@ -861,6 +863,7 @@ export async function isPageStatic({ isRoutePPREnabled, partialFallbacksEnabled, buildId, + deploymentId, rootParamKeys, })) } diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 411cc61c5c3d..4b8d19ed5e3a 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -496,7 +496,7 @@ export async function createFetch( // search param to it. This should not leak outside of this function, so we // track them separately. let fetchUrl = new URL(url) - setCacheBustingSearchParam(fetchUrl, headers) + await setCacheBustingSearchParam(fetchUrl, headers) let processed = fetch(fetchUrl, fetchOptions).then(processFetch) let fetchPromise = processed.then(({ response }) => response) @@ -566,7 +566,7 @@ export async function createFetch( // fetch again. // TODO: We should abort the previous request. fetchUrl = new URL(responseUrl) - setCacheBustingSearchParam(fetchUrl, headers) + await setCacheBustingSearchParam(fetchUrl, headers) processed = fetch(fetchUrl, fetchOptions).then(processFetch) fetchPromise = processed.then(({ response }) => response) flightResponsePromise = shouldImmediatelyDecode diff --git a/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.test.ts b/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.test.ts new file mode 100644 index 000000000000..658cde8cb075 --- /dev/null +++ b/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.test.ts @@ -0,0 +1,48 @@ +import { + NEXT_ROUTER_PREFETCH_HEADER, + NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, + NEXT_ROUTER_STATE_TREE_HEADER, + NEXT_RSC_UNION_QUERY, + NEXT_URL, +} from '../app-router-headers' +import { setCacheBustingSearchParam } from './set-cache-busting-search-param' +import { computeLegacyCacheBustingSearchParam } from '../../../shared/lib/router/utils/cache-busting-search-param' + +describe('setCacheBustingSearchParam', () => { + const originalCryptoDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto' + ) + + afterEach(() => { + if (originalCryptoDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalCryptoDescriptor) + } + }) + + it('falls back to the legacy hash when Web Crypto is unavailable', async () => { + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: {}, + }) + + const headers = { + [NEXT_ROUTER_PREFETCH_HEADER]: '1', + [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: '/_tree', + [NEXT_ROUTER_STATE_TREE_HEADER]: '%5B%22%22%2C%7B%7D%5D', + [NEXT_URL]: '/pcsta0', + } as const + const url = new URL('https://example.com/') + + await setCacheBustingSearchParam(url, headers) + + expect(url.searchParams.get(NEXT_RSC_UNION_QUERY)).toBe( + computeLegacyCacheBustingSearchParam( + headers[NEXT_ROUTER_PREFETCH_HEADER], + headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER], + headers[NEXT_ROUTER_STATE_TREE_HEADER], + headers[NEXT_URL] + ) + ) + }) +}) diff --git a/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts b/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts index a57b9c1b183f..1267ea3df889 100644 --- a/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts +++ b/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts @@ -1,6 +1,9 @@ 'use client' -import { computeCacheBustingSearchParam } from '../../../shared/lib/router/utils/cache-busting-search-param' +import { + computeCacheBustingSearchParam, + computeLegacyCacheBustingSearchParam, +} from '../../../shared/lib/router/utils/cache-busting-search-param' import { NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, @@ -10,6 +13,26 @@ import { } from '../app-router-headers' import type { RequestHeaders } from './fetch-server-response' +async function computeClientCacheBustingSearchParam( + headers: RequestHeaders +): Promise { + if (typeof globalThis.crypto?.subtle?.digest === 'function') { + return computeCacheBustingSearchParam( + headers[NEXT_ROUTER_PREFETCH_HEADER], + headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER], + headers[NEXT_ROUTER_STATE_TREE_HEADER], + headers[NEXT_URL] + ) + } + + return computeLegacyCacheBustingSearchParam( + headers[NEXT_ROUTER_PREFETCH_HEADER], + headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER], + headers[NEXT_ROUTER_STATE_TREE_HEADER], + headers[NEXT_URL] + ) +} + /** * Mutates the provided URL by adding a cache-busting search parameter for CDNs that don't * support custom headers. This helps avoid caching conflicts by making each request unique. @@ -21,21 +44,17 @@ import type { RequestHeaders } from './fetch-server-response' * URL before: https://example.com/path?query=1 * URL after: https://example.com/path?query=1&_rsc=abc123 * - * Note: This function mutates the input URL directly and does not return anything. + * Note: This function mutates the input URL directly and resolves once the + * cache-busting value has been computed and applied. * * TODO: Since we need to use a search param anyway, we could simplify by removing the custom * headers approach entirely and just use search params. */ -export const setCacheBustingSearchParam = ( +export const setCacheBustingSearchParam = async ( url: URL, headers: RequestHeaders -): void => { - const uniqueCacheKey = computeCacheBustingSearchParam( - headers[NEXT_ROUTER_PREFETCH_HEADER], - headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER], - headers[NEXT_ROUTER_STATE_TREE_HEADER], - headers[NEXT_URL] - ) +): Promise => { + const uniqueCacheKey = await computeClientCacheBustingSearchParam(headers) setCacheBustingSearchParamWithHash(url, uniqueCacheKey) } @@ -50,7 +69,7 @@ export const setCacheBustingSearchParam = ( * hash: "abc123" * URL after: https://example.com/path?query=1&_rsc=abc123 * - * If the hash is null, we will set `_rsc` search param without a value. + * If the hash is empty, we will set `_rsc` search param without a value. * Like this: https://example.com/path?query=1&_rsc * * Note: This function mutates the input URL directly and does not return anything. diff --git a/packages/next/src/client/route-params.ts b/packages/next/src/client/route-params.ts index e29e54b89445..67947f549cc7 100644 --- a/packages/next/src/client/route-params.ts +++ b/packages/next/src/client/route-params.ts @@ -49,6 +49,24 @@ export function getRenderedPathname( .pathname) as NormalizedPathname } +// Pathname parts come from `URL.pathname.split('/')`, so they are already +// in the encoded form the URL parser produces. The server-side equivalent +// (`get-dynamic-param.ts`) starts from a decoded param value and applies +// `encodeURIComponent` once. The two encodings are not the same — for +// example, the URL parser leaves `,` and `:` untouched while +// `encodeURIComponent` percent-encodes them. To produce the same canonical +// form on the client (and avoid double-encoding `%xx` sequences such as +// `%2F` → `%252F`), we decode the URL part first and re-encode it. +function canonicalizeURLPart(part: string): string { + try { + return encodeURIComponent(decodeURIComponent(part)) + } catch { + // `decodeURIComponent` throws on malformed sequences. Fall back to the + // already-encoded form rather than failing the navigation. + return part + } +} + export function parseDynamicParamFromURLPart( paramType: DynamicParamTypesShort, pathnameParts: Array, @@ -61,7 +79,7 @@ export function parseDynamicParamFromURLPart( // Catchalls receive all the remaining URL parts. If there are no // remaining pathname parts, return an empty array. return partIndex < pathnameParts.length - ? pathnameParts.slice(partIndex).map((s) => encodeURIComponent(s)) + ? pathnameParts.slice(partIndex).map((s) => canonicalizeURLPart(s)) : [] } // Catchall intercepted @@ -73,10 +91,10 @@ export function parseDynamicParamFromURLPart( return partIndex < pathnameParts.length ? pathnameParts.slice(partIndex).map((s, i) => { if (i === 0) { - return encodeURIComponent(s.slice(prefix)) + return canonicalizeURLPart(s.slice(prefix)) } - return encodeURIComponent(s) + return canonicalizeURLPart(s) }) : [] } @@ -85,7 +103,7 @@ export function parseDynamicParamFromURLPart( // Optional catchalls receive all the remaining URL parts, unless this is // the end of the pathname, in which case they return null. return partIndex < pathnameParts.length - ? pathnameParts.slice(partIndex).map((s) => encodeURIComponent(s)) + ? pathnameParts.slice(partIndex).map((s) => canonicalizeURLPart(s)) : null } // Dynamic @@ -100,7 +118,7 @@ export function parseDynamicParamFromURLPart( // recovery options. return '' } - return encodeURIComponent(pathnameParts[partIndex]) + return canonicalizeURLPart(pathnameParts[partIndex]) } // Dynamic intercepted case 'di(..)(..)': @@ -119,7 +137,7 @@ export function parseDynamicParamFromURLPart( return '' } - return encodeURIComponent(pathnameParts[partIndex].slice(prefix)) + return canonicalizeURLPart(pathnameParts[partIndex].slice(prefix)) } default: paramType satisfies never diff --git a/packages/next/src/client/script.tsx b/packages/next/src/client/script.tsx index e28724e894cc..d1bde2e38207 100644 --- a/packages/next/src/client/script.tsx +++ b/packages/next/src/client/script.tsx @@ -6,6 +6,7 @@ import type { ScriptHTMLAttributes } from 'react' import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime' import { setAttributesFromProps } from './set-attributes-from-props' import { requestIdleCallback } from './request-idle-callback' +import { htmlEscapeJsonString } from '../shared/lib/htmlescape' const ScriptCache = new Map() const LoadCache = new Set() @@ -327,10 +328,9 @@ function Script(props: ScriptProps): JSX.Element | null { "'`) + ).toBeUndefined() + expect( + getScriptNonceFromHeader(`script-src 'nonce-" onerror="alert(1)'`) + ).toBeUndefined() + }) + + it('skips malformed nonce values and keeps looking for a valid one', () => { + expect( + getScriptNonceFromHeader( + `script-src 'nonce-" onerror="alert(1)' 'nonce-cmFuZG9tCg=='` + ) + ).toBe('cmFuZG9tCg==') + }) +}) diff --git a/packages/next/src/server/app-render/get-script-nonce-from-header.tsx b/packages/next/src/server/app-render/get-script-nonce-from-header.tsx index 63b20a005fc3..45ce83c00ae0 100644 --- a/packages/next/src/server/app-render/get-script-nonce-from-header.tsx +++ b/packages/next/src/server/app-render/get-script-nonce-from-header.tsx @@ -1,4 +1,4 @@ -import { ESCAPE_REGEX } from '../htmlescape' +const CSP_NONCE_SOURCE_REGEX = /^'nonce-([A-Za-z0-9+/_-]+={0,2})'$/ export function getScriptNonceFromHeader( cspHeaderValue: string @@ -19,35 +19,13 @@ export function getScriptNonceFromHeader( return } - // Extract the nonce from the directive - const nonce = directive - .split(' ') - // Remove the 'strict-src'/'default-src' string, this can't be the nonce. - .slice(1) - .map((source) => source.trim()) - // Find the first source with the 'nonce-' prefix. - .find( - (source) => - source.startsWith("'nonce-") && - source.length > 8 && - source.endsWith("'") - ) - // Grab the nonce by trimming the 'nonce-' prefix. - ?.slice(7, -1) + // Extract the first valid nonce from the directive. Malformed nonces are + // ignored so the request can continue without a nonce instead of failing. + for (const source of directive.split(/\s+/).slice(1)) { + const match = source.trim().match(CSP_NONCE_SOURCE_REGEX) - // If we could't find the nonce, then we're done. - if (!nonce) { - return - } - - // Don't accept the nonce value if it contains HTML escape characters. - // Technically, the spec requires a base64'd value, but this is just an - // extra layer. - if (ESCAPE_REGEX.test(nonce)) { - throw new Error( - 'Nonce value from Content-Security-Policy contained HTML escape characters.\nLearn more: https://nextjs.org/docs/messages/nonce-contained-invalid-characters' - ) + if (match) { + return match[1] + } } - - return nonce } diff --git a/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts new file mode 100644 index 000000000000..f6b386763d03 --- /dev/null +++ b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts @@ -0,0 +1,12 @@ +import { createServerInsertedMetadata } from './create-server-inserted-metadata' + +describe('createServerInsertedMetadata', () => { + it('escapes nonce attribute values in raw HTML output', async () => { + const getServerInsertedMetadata = + createServerInsertedMetadata(`" onerror="alert(1)`) + + await expect(getServerInsertedMetadata()).resolves.toContain( + '` + return `${REINSERT_ICON_SCRIPT}` } } diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 5f148e1a0a43..2b6204bbe29b 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -1,7 +1,10 @@ import type { BinaryStreamOf } from './app-render' import type { Readable } from 'node:stream' -import { htmlEscapeJsonString } from '../htmlescape' +import { + htmlEscapeAttributeString, + htmlEscapeJsonString, +} from '../../shared/lib/htmlescape' import { workUnitAsyncStorage } from './work-unit-async-storage.external' import { InvariantError } from '../../shared/lib/invariant-error' import { getClientReferenceManifest } from './manifests-singleton' @@ -157,7 +160,7 @@ export function createInlinedDataReadableStream( formState: unknown | null ): ReadableStream { const startScriptTag = nonce - ? `` breaks out of the script element at the +// HTML tokenizer level, executes, and the fingerprint it leaves on `window` +// is picked up by . +export default function Page() { + return ( +
+ " + > + {/* Same idea, exercising `>` and `&&` (contains `&`). */} + {`window.__escapeProofInlineChildren = 2 > 1 && 3 > 2;console.log('running children script');`} + + ` break-out. +// 2. Whether each legitimate beforeInteractive `` inside terminates the inline __next_s push + // script at the HTML tokenizer level and the trailing ' + ) + expect(html).not.toContain( + '' + ) + expect(html).not.toContain('') + + // The fixture's exposes each result as a `data-*` + // attribute on `[data-testid="xss-status"]`. `data-ready="true"` flips + // after the effect populates the results. + // + // - `data-xss-*` must all be "false" (no injected script ran) + // - `data-escape-proof-*` must all be "true" for the inline scripts — + // their bodies evaluate a `<`/`>`/`&&` expression, so `true` proves + // both "the script ran" and "the HTML-escape round-trip didn't + // mangle the source" + // - `data-loaded-src` must be "true" (external script fetched and ran) + const browser = await next.browser('/') + const getAttr = (name: string) => + browser.elementByCss('[data-testid="xss-status"]').getAttribute(name) + + await retry(async () => { + expect(await getAttr('data-ready')).toBe('true') + }) + + expect(await getAttr('data-xss-inline-innerhtml')).toBe('false') + expect(await getAttr('data-xss-inline-children')).toBe('false') + expect(await getAttr('data-xss-src')).toBe('false') + expect(await getAttr('data-escape-proof-inline-innerhtml')).toBe('true') + expect(await getAttr('data-escape-proof-inline-children')).toBe('true') + expect(await getAttr('data-loaded-src')).toBe('true') + }) +}) diff --git a/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts b/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts index aabbb2919acc..65d116bf3991 100644 --- a/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts +++ b/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts @@ -103,6 +103,31 @@ describe('segment cache (CDN cache busting)', () => { } ) + it('ignores invalid RSC header values when serving a document request', async () => { + const url = new URL(`http://localhost:${port}/target-page`) + url.searchParams.set('test', 'invalid-rsc-header') + + const invalidHeaderRes = await fetch(url, { + headers: { + rsc: '0', + }, + }) + + expect(invalidHeaderRes.status).toBe(200) + expect(invalidHeaderRes.headers.get('content-type')).toContain('text/html') + expect(await invalidHeaderRes.text()).toContain( + '
Target page
' + ) + + const htmlRes = await fetch(url) + + expect(htmlRes.status).toBe(200) + expect(htmlRes.headers.get('content-type')).toContain('text/html') + expect(await htmlRes.text()).toContain( + '
Target page
' + ) + }) + it( 'perform fully prefetched navigation when a third-party proxy ' + 'performs a redirect', diff --git a/test/e2e/app-dir/segment-cache/encoded-slash-params/app/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/encoded-slash-params/app/[slug]/page.tsx new file mode 100644 index 000000000000..02a080e37d89 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/encoded-slash-params/app/[slug]/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from 'react' + +type Params = { slug: string } + +async function PageContent({ params }: { params: Promise }) { + const { slug } = await params + return
{`slug=${slug}`}
+} + +export default function SlugPage({ params }: { params: Promise }) { + return ( + Loading…
}> + + + ) +} diff --git a/test/e2e/app-dir/segment-cache/encoded-slash-params/app/layout.tsx b/test/e2e/app-dir/segment-cache/encoded-slash-params/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/segment-cache/encoded-slash-params/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/encoded-slash-params/app/page.tsx b/test/e2e/app-dir/segment-cache/encoded-slash-params/app/page.tsx new file mode 100644 index 000000000000..ed2b495bd554 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/encoded-slash-params/app/page.tsx @@ -0,0 +1,27 @@ +import { connection } from 'next/server' +import { Suspense } from 'react' +import { LinkAccordion } from '../components/link-accordion' + +// `connection()` ensures the hub is dynamically rendered. +async function HubContent() { + await connection() + return
Hub
+} + +export default function HubPage() { + return ( +
+ Loading…
}> + + +
    +
  • + unencoded +
  • +
  • + encoded slash +
  • +
+ + ) +} diff --git a/test/e2e/app-dir/segment-cache/encoded-slash-params/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/encoded-slash-params/components/link-accordion.tsx new file mode 100644 index 000000000000..7232ef98b247 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/encoded-slash-params/components/link-accordion.tsx @@ -0,0 +1,33 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ + href, + prefetch, + children, +}: { + href: string + prefetch?: boolean + children: React.ReactNode +}) { + const [isVisible, setIsVisible] = useState(false) + return ( + <> + setIsVisible(!isVisible)} + data-link-accordion={href} + /> + {isVisible ? ( + + {children} + + ) : ( + <>{children} (link is hidden) + )} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts b/test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts new file mode 100644 index 000000000000..cae616233370 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts @@ -0,0 +1,77 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +/** + * When a dynamic param contains a percent-encoded forward slash (`%2F`), the + * client double-encodes the URL part when deriving its segment cache key, while + * the server encodes the decoded value once. The resulting segment mismatch + * causes the navigation reducer to invalidate the entire route cache, so any + * later prefetch for the same URL re-fetches the route tree + * (`next-router-segment-prefetch: /_tree`). + * + * The fixture is intentionally fully dynamic (no `generateStaticParams`): a + * statically prerendered file is served from the CDN without the `%2F` + * round-trip the bug depends on. + */ +describe('segment cache - encoded slash params', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + if (isNextDev) { + test('prefetching is disabled in dev mode', () => {}) + return + } + + describe.each([ + { label: 'unencoded param', href: '/foo' }, + { label: 'encoded slash in param', href: '/foo%2Fbar' }, + ])('$label', ({ href }) => { + it('back navigation does not refetch the route tree', async () => { + let act: ReturnType + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Reveal the link — wait for the prefetch to settle. + await act(async () => { + const toggle = await browser.elementByCss( + `input[data-link-accordion="${href}"]` + ) + await toggle.click() + }) + + // Click through. The prefetched data should satisfy this nav. + const link = await browser.elementByCss(`a[href="${href}"]`) + await act(async () => { + await link.click() + }) + await browser.elementByCss('[data-slug-page]') + + // Browser back to the home page, then ensure the link is revealed. The + // route tree cache already has an entry for the URL — the re-prefetch on + // Link mount must not fire any requests. With the encoded-slash bug, the + // cache lookup misses and a `next-router-segment-prefetch: /_tree` + // request goes out. + await act(async () => { + await browser.back() + await browser.elementById('hub') + + // BFCache restoration of `useState` is browser-dependent. If the Link + // isn't in the DOM (checkbox unchecked), click the toggle to reveal it. + const linkSelector = `a[href="${href}"]` + if (!(await browser.hasElementByCssSelector(linkSelector))) { + const toggle = await browser.elementByCss( + `input[data-link-accordion="${href}"]` + ) + await toggle.click() + } + + await browser.elementByCss(linkSelector) + }, 'no-requests') + }) + }) +}) diff --git a/test/e2e/app-dir/segment-cache/encoded-slash-params/next.config.js b/test/e2e/app-dir/segment-cache/encoded-slash-params/next.config.js new file mode 100644 index 000000000000..e64bae22d658 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/encoded-slash-params/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/domains/loading.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/domains/loading.tsx new file mode 100644 index 000000000000..f879ac8a16ad --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/domains/loading.tsx @@ -0,0 +1,7 @@ +export default function ProjectDomainsSettingsLoading() { + return ( +
+ Loading project domains settings... +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/domains/page.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/domains/page.tsx new file mode 100644 index 000000000000..c0cab7796fce --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/domains/page.tsx @@ -0,0 +1,88 @@ +import { cacheLife } from 'next/cache' +import Link from 'next/link' +import { Suspense } from 'react' +import { LinkAccordion } from '../../../../../../components/link-accordion' + +type Params = { teamSlug: string; project: string } + +export default function ProjectDomainsSettingsPage({ + params, +}: { + params: Promise +}) { + return ( +
+ + Loading project settings domains page... +
+ } + > + + + +
+ + Related route: acme/dashboard/settings/domains + + + Related route: globex/portal/settings/domains + +
+ + ) +} + +async function ProjectDomainsSettingsContent({ + params, +}: { + params: Promise +}) { + 'use cache' + cacheLife({ stale: 0, revalidate: 1, expire: 60 }) + + const { teamSlug, project } = await params + const marker = Date.now() + + return ( +
+ {`Project domains settings content - team: ${teamSlug}, project: ${project}, marker: ${marker}`} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/layout.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/layout.tsx new file mode 100644 index 000000000000..75ecc8b6c060 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/(domains)/settings/layout.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react' + +export default function ProjectSettingsLayout({ + children, +}: { + children: ReactNode +}) { + return ( +
+

Project Settings Layout (domains variant)

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/loading.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/loading.tsx new file mode 100644 index 000000000000..ea4c925adbf2 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/loading.tsx @@ -0,0 +1,5 @@ +export default function TeamProjectLoading() { + return ( +
Loading team project page...
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/page.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/page.tsx new file mode 100644 index 000000000000..e9362ade768d --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/page.tsx @@ -0,0 +1,101 @@ +import { cacheLife } from 'next/cache' +import Link from 'next/link' +import { Suspense } from 'react' +import { LinkAccordion } from '../../../components/link-accordion' + +type Params = { teamSlug: string; project: string } + +export default function TeamProjectPage({ + params, +}: { + params: Promise +}) { + return ( +
+ Loading team/project route...
} + > + + + +
+ + Related route: acme/dashboard + + + Related route: globex/portal + + + Related route: acme/dashboard/settings/domains + + + Related route: globex/portal/settings/domains + +
+ + ) +} + +async function TeamProjectContent({ params }: { params: Promise }) { + 'use cache' + cacheLife({ stale: 0, revalidate: 1, expire: 60 }) + + const { teamSlug, project } = await params + const marker = Date.now() + + return ( +
+ {`Team project content - team: ${teamSlug}, project: ${project}, marker: ${marker}`} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/settings/layout.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/settings/layout.tsx new file mode 100644 index 000000000000..c1ab6f233cc8 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/settings/layout.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react' + +export default function ProjectSettingsLayout({ + children, +}: { + children: ReactNode +}) { + return ( +
+

Project Settings Layout

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/settings/page.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/settings/page.tsx new file mode 100644 index 000000000000..20d1c67c5867 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/settings/page.tsx @@ -0,0 +1,69 @@ +import { cacheLife } from 'next/cache' +import Link from 'next/link' +import { Suspense } from 'react' +import { LinkAccordion } from '../../../../components/link-accordion' + +type Params = { teamSlug: string; project: string } + +export default function ProjectSettingsPage({ + params, +}: { + params: Promise +}) { + return ( +
+ Loading settings page...
} + > + + + +
+ + Related route: acme/dashboard/settings/domains + + + Related route: globex/portal/settings/domains + +
+ + ) +} + +async function ProjectSettingsContent({ params }: { params: Promise }) { + 'use cache' + cacheLife({ stale: 0, revalidate: 1, expire: 60 }) + + const { teamSlug, project } = await params + const marker = Date.now() + + return ( +
+ {`Project settings overview content - team: ${teamSlug}, project: ${project}, marker: ${marker}`} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/api/revalidate-layout/route.ts b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/api/revalidate-layout/route.ts new file mode 100644 index 000000000000..d094cc7024b8 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/api/revalidate-layout/route.ts @@ -0,0 +1,59 @@ +import { revalidatePath, revalidateTag } from 'next/cache' +import { NextRequest } from 'next/server' + +type RevalidateMode = + | 'tag-layout-expireNow' + | 'tag-layout-max' + | 'tag-layout-legacy' + | 'path-root-layout' + | 'path-team-layout' + | 'path-team-page' + +function isRevalidateMode(value: string): value is RevalidateMode { + return ( + value === 'tag-layout-expireNow' || + value === 'tag-layout-max' || + value === 'tag-layout-legacy' || + value === 'path-root-layout' || + value === 'path-team-layout' || + value === 'path-team-page' + ) +} + +export async function GET(request: NextRequest) { + const modeQuery = request.nextUrl.searchParams.get('mode') + const mode: RevalidateMode = + modeQuery && isRevalidateMode(modeQuery) + ? modeQuery + : 'tag-layout-expireNow' + + switch (mode) { + case 'tag-layout-expireNow': + revalidateTag('_N_T_/layout', 'expireNow') + break + case 'tag-layout-max': + revalidateTag('_N_T_/layout', 'max') + break + case 'tag-layout-legacy': + // Intentionally test the deprecated call shape without a profile arg. + // @ts-expect-error + revalidateTag('_N_T_/layout') + break + case 'path-root-layout': + revalidatePath('/', 'layout') + break + case 'path-team-layout': + revalidatePath('/acme/dashboard', 'layout') + break + case 'path-team-page': + revalidatePath('/acme/dashboard') + break + default: + break + } + + return Response.json({ + revalidated: true, + mode, + }) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/layout.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/page.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/page.tsx new file mode 100644 index 000000000000..6e45a2dd46cd --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/page.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link' +import { LinkAccordion } from '../components/link-accordion' +import { RevalidateControls } from '../components/revalidate-controls' + +export default function HomePage() { + return ( +
+

Root Dynamic Route Vary Params

+

+ Prefetch dynamic team/project routes and validate segment payload + params. +

+
    +
  • + + Navigate: acme/dashboard + +
  • +
  • + + Navigate: globex/portal + +
  • +
  • + + Navigate: acme/dashboard/settings/domains + +
  • +
  • + + Navigate: globex/portal/settings/domains + +
  • +
+
    +
  • + + Team project: acme/dashboard + +
  • +
  • + + Team project: globex/portal + +
  • +
+ +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/revalidate-actions.ts b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/revalidate-actions.ts new file mode 100644 index 000000000000..011516d19b1e --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/revalidate-actions.ts @@ -0,0 +1,29 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +export async function revalidateLayoutByTagExpireNowAction() { + revalidateTag('_N_T_/layout', 'expireNow') + return { + mode: 'server-action-tag-layout-expireNow', + revalidated: true, + } +} + +export async function revalidateLayoutByTagMaxAction() { + revalidateTag('_N_T_/layout', 'max') + return { + mode: 'server-action-tag-layout-max', + revalidated: true, + } +} + +export async function revalidateLayoutByTagLegacyAction() { + // Intentionally test the deprecated call shape without a profile arg. + // @ts-expect-error + revalidateTag('_N_T_/layout') + return { + mode: 'server-action-tag-layout-legacy', + revalidated: true, + } +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/link-accordion.tsx new file mode 100644 index 000000000000..6675b8716262 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/link-accordion.tsx @@ -0,0 +1,35 @@ +'use client' + +import Link, { type LinkProps } from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ + href, + children, + prefetch, +}: { + href: string + children?: React.ReactNode + prefetch?: LinkProps['prefetch'] +}) { + const [isVisible, setIsVisible] = useState(false) + const displayChildren = children !== undefined ? children : href + + return ( + <> + setIsVisible(!isVisible)} + data-link-accordion={href} + /> + {isVisible ? ( + + {displayChildren} + + ) : ( + <>{displayChildren} (link is hidden) + )} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/revalidate-controls.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/revalidate-controls.tsx new file mode 100644 index 000000000000..9fc5ce529ca7 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/revalidate-controls.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState } from 'react' +import { + revalidateLayoutByTagExpireNowAction, + revalidateLayoutByTagLegacyAction, + revalidateLayoutByTagMaxAction, +} from '../app/revalidate-actions' + +type ApiRevalidateMode = + | 'tag-layout-expireNow' + | 'tag-layout-max' + | 'tag-layout-legacy' + | 'path-root-layout' + | 'path-team-layout' + | 'path-team-page' +type ServerActionMode = + | 'server-action-tag-layout-expireNow' + | 'server-action-tag-layout-max' + | 'server-action-tag-layout-legacy' + +export function RevalidateControls() { + const [result, setResult] = useState('idle') + const [isPending, setIsPending] = useState(false) + + const runApiRevalidate = async (mode: ApiRevalidateMode) => { + setIsPending(true) + try { + const response = await fetch(`/api/revalidate-layout?mode=${mode}`, { + method: 'GET', + }) + const data = await response.json() + setResult(JSON.stringify(data)) + } catch (error) { + setResult( + JSON.stringify({ + error: error instanceof Error ? error.message : 'unknown error', + mode, + }) + ) + } finally { + setIsPending(false) + } + } + + const runServerActionRevalidate = async (mode: ServerActionMode) => { + setIsPending(true) + try { + const data = + mode === 'server-action-tag-layout-expireNow' + ? await revalidateLayoutByTagExpireNowAction() + : mode === 'server-action-tag-layout-max' + ? await revalidateLayoutByTagMaxAction() + : await revalidateLayoutByTagLegacyAction() + setResult(JSON.stringify(data)) + } catch (error) { + setResult( + JSON.stringify({ + error: error instanceof Error ? error.message : 'unknown error', + mode, + }) + ) + } finally { + setIsPending(false) + } + } + + return ( +
+ + + + + + +
+ {result} +
+
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js new file mode 100644 index 000000000000..6e2212044dc0 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js @@ -0,0 +1,19 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + cacheLife: { + expireNow: { + stale: 0, + revalidate: 0, + expire: 0, + }, + }, + experimental: { + optimisticRouting: true, + varyParams: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts new file mode 100644 index 000000000000..f32b223f168b --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts @@ -0,0 +1,550 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry, waitFor } from 'next-test-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +type RevalidateMode = + | 'tag-layout-expireNow' + | 'tag-layout-max' + | 'tag-layout-legacy' + | 'path-root-layout' + | 'path-team-layout' + | 'path-team-page' +type BrowserRevalidateMode = + | 'tag-layout-expireNow' + | 'tag-layout-max' + | 'tag-layout-legacy' + | 'server-action-tag-layout-expireNow' + | 'server-action-tag-layout-max' + | 'server-action-tag-layout-legacy' + +type SegmentPrefetchResponse = { + body: string + requestPathname: string + requestSegmentPath: string + segmentPrefetchPath: string + status: number +} + +describe('segment cache - vary params base dynamic', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + if (isNextDev) { + test('prefetching is disabled in dev mode', () => {}) + return + } + + const expectedTextByHref: Record = { + '/acme/dashboard': 'Team project content - team: acme, project: dashboard', + '/globex/portal': 'Team project content - team: globex, project: portal', + '/acme/dashboard/settings': + 'Project settings overview content - team: acme, project: dashboard', + '/globex/portal/settings': + 'Project settings overview content - team: globex, project: portal', + '/acme/dashboard/settings/domains': + 'Project domains settings content - team: acme, project: dashboard', + '/globex/portal/settings/domains': + 'Project domains settings content - team: globex, project: portal', + } + + const toSegmentPrefetchResponse = ( + response: Playwright.Response + ): Promise | null => { + const request = response.request() + const segmentPath = request.headers()['next-router-segment-prefetch'] + + if (!segmentPath) { + return null + } + + const pathname = new URL(request.url()).pathname + const segmentPrefetchPath = pathname.endsWith('.rsc') + ? `${pathname.slice(0, -'.rsc'.length)}.segments${segmentPath}.segment.rsc` + : `${pathname}.segments${segmentPath}.segment.rsc` + + return response + .text() + .then((body) => ({ + body, + requestPathname: pathname, + requestSegmentPath: segmentPath, + segmentPrefetchPath, + status: response.status(), + })) + .catch(() => ({ + body: '', + requestPathname: pathname, + requestSegmentPath: segmentPath, + segmentPrefetchPath, + status: response.status(), + })) + } + + const collectSegmentPrefetchResponses = async ( + href: string, + startPath: string = '/' + ) => { + let act: ReturnType + const segmentPrefetchResponses: Array> = [] + + const browser = await next.browser(startPath, { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + p.on('response', (response) => { + const prefetchResponse = toSegmentPrefetchResponse(response) + if (prefetchResponse !== null) { + segmentPrefetchResponses.push(prefetchResponse) + } + }) + }, + }) + + await act(async () => { + const toggle = await browser.elementByCss( + `input[data-link-accordion="${href}"]` + ) + await toggle.click() + }) + + const settledResponses = await Promise.all(segmentPrefetchResponses) + await browser.close() + + return settledResponses + } + + const collectSegmentPrefetchResponsesFromBackForwardNavigation = async ( + browserRevalidateMode?: BrowserRevalidateMode + ) => { + const segmentPrefetchResponses: Array> = [] + const pageErrors: Array = [] + + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + p.on('response', (response) => { + const prefetchResponse = toSegmentPrefetchResponse(response) + if (prefetchResponse !== null) { + segmentPrefetchResponses.push(prefetchResponse) + } + }) + p.on('pageerror', (error) => { + pageErrors.push(error.message) + }) + }, + }) + + const expectHomePage = async () => { + await retry(async () => { + const content = await browser.elementByCss('#home-page') + expect(await content.text()).toContain('Root Dynamic Route Vary Params') + }) + } + + const expectTeamPage = async ( + href: '/acme/dashboard' | '/globex/portal' + ) => { + const expectedText = expectedTextByHref[href] + + await retry(async () => { + const content = await browser.elementByCss( + '[data-team-project-content]' + ) + expect(await content.text()).toContain(expectedText) + }) + } + + const clickVisibleLink = async (href: string) => { + const link = await browser.elementByCss(`a[data-nav-link="${href}"]`) + await link.click() + } + + await expectHomePage() + + if (browserRevalidateMode) { + const button = await browser.elementById( + `revalidate-in-browser-${browserRevalidateMode}` + ) + await button.click() + + await retry(async () => { + const result = await browser.elementById('revalidate-result').text() + expect(result).toContain('"revalidated":true') + expect(result).toContain(`"mode":"${browserRevalidateMode}"`) + }) + + await waitFor(500) + } + + await waitFor(300) + + await clickVisibleLink('/acme/dashboard') + await expectTeamPage('/acme/dashboard') + + for (let cycle = 0; cycle < 3; cycle++) { + await browser.back() + await expectHomePage() + await waitFor(250) + + await browser.forward() + await expectTeamPage('/acme/dashboard') + await waitFor(250) + } + + await clickVisibleLink('/globex/portal') + await expectTeamPage('/globex/portal') + + for (let cycle = 0; cycle < 2; cycle++) { + await browser.back() + await expectTeamPage('/acme/dashboard') + await waitFor(250) + + await browser.forward() + await expectTeamPage('/globex/portal') + await waitFor(250) + } + + await browser.back() + await expectTeamPage('/acme/dashboard') + await browser.back() + await expectHomePage() + await waitFor(500) + + const settledResponses = await Promise.all(segmentPrefetchResponses) + await browser.close() + + return { + pageErrors, + responses: settledResponses, + } + } + + const collectSegmentPrefetchResponsesFromProductionShapeNavigation = + async () => { + const segmentPrefetchResponses: Array> = + [] + const pageErrors: Array = [] + + const browser = await next.browser('/acme/dashboard/settings', { + beforePageLoad(p: Playwright.Page) { + p.on('response', (response) => { + const prefetchResponse = toSegmentPrefetchResponse(response) + if (prefetchResponse !== null) { + segmentPrefetchResponses.push(prefetchResponse) + } + }) + p.on('pageerror', (error) => { + pageErrors.push(error.message) + }) + }, + }) + + const clickVisibleLink = async (href: string) => { + const link = await browser.elementByCss(`a[data-nav-link="${href}"]`) + await link.click() + } + + const expectProjectSettingsPage = async ( + team: 'acme' | 'globex', + project: 'dashboard' | 'portal' + ) => { + const expectedText = expectedTextByHref[`/${team}/${project}/settings`] + await retry(async () => { + const content = await browser.elementByCss( + '[data-team-project-settings-content]' + ) + expect(await content.text()).toContain(expectedText) + }) + } + + const expectProjectDomainsPage = async ( + team: 'acme' | 'globex', + project: 'dashboard' | 'portal' + ) => { + const expectedText = + expectedTextByHref[`/${team}/${project}/settings/domains`] + await retry(async () => { + const content = await browser.elementByCss( + '[data-team-project-settings-domains-content]' + ) + expect(await content.text()).toContain(expectedText) + }) + } + + await expectProjectSettingsPage('acme', 'dashboard') + await waitFor(300) + + await clickVisibleLink('/acme/dashboard/settings/domains') + await expectProjectDomainsPage('acme', 'dashboard') + + for (let cycle = 0; cycle < 3; cycle++) { + await browser.back() + await expectProjectSettingsPage('acme', 'dashboard') + await waitFor(250) + + await browser.forward() + await expectProjectDomainsPage('acme', 'dashboard') + await waitFor(250) + } + + await clickVisibleLink('/globex/portal/settings/domains') + await expectProjectDomainsPage('globex', 'portal') + + for (let cycle = 0; cycle < 2; cycle++) { + await browser.back() + await expectProjectDomainsPage('acme', 'dashboard') + await waitFor(250) + + await browser.forward() + await expectProjectDomainsPage('globex', 'portal') + await waitFor(250) + } + + await browser.back() + await expectProjectDomainsPage('acme', 'dashboard') + await browser.back() + await expectProjectSettingsPage('acme', 'dashboard') + await waitFor(500) + + const settledResponses = await Promise.all(segmentPrefetchResponses) + await browser.close() + + return { + pageErrors, + responses: settledResponses, + } + } + + const assertNoEncodedDynamicPlaceholders = (value: string) => { + expect(value.includes('%5BteamSlug%5D')).toBe(false) + expect(value.includes('%5Bproject%5D')).toBe(false) + expect(value.includes('%255BteamSlug%255D')).toBe(false) + expect(value.includes('%255Bproject%255D')).toBe(false) + expect(value.includes('[teamSlug]')).toBe(false) + expect(value.includes('[project]')).toBe(false) + } + + const assertValidSegmentResponses = ( + responses: Array, + expectedRoutePrefixes: Array = ['/acme/dashboard', '/globex/portal'] + ) => { + const bodies = responses.map((response) => response.body) + const requestPathnames = responses.map( + (response) => response.requestPathname + ) + const requestSegmentPaths = responses.map( + (response) => response.requestSegmentPath + ) + + // Webpack flight payloads can include module chunk references like: + // `static/chunks/app/%5Bslug%5D/page-*.js`. These are build artifact paths, + // not route params, so strip them before placeholder assertions. + const cleanedBodies = bodies.map((body) => + body.replace(/static\/chunks\/app\/[^"'\n]+\.js/g, '') + ) + const allBodies = cleanedBodies.join('\n') + const allRequestPathnames = requestPathnames.join('\n') + const allRequestSegmentPaths = requestSegmentPaths.join('\n') + const segmentPrefetchPaths = [ + ...new Set(responses.map((response) => response.segmentPrefetchPath)), + ] + + expect(bodies.length).toBeGreaterThan(0) + expect(responses.some((response) => response.status >= 400)).toBe(false) + + assertNoEncodedDynamicPlaceholders(allBodies) + assertNoEncodedDynamicPlaceholders(allRequestPathnames) + assertNoEncodedDynamicPlaceholders(allRequestSegmentPaths) + + expect(segmentPrefetchPaths.some((path) => path.includes('%5B'))).toBe( + false + ) + expect(segmentPrefetchPaths.some((path) => path.includes('%255B'))).toBe( + false + ) + expect( + segmentPrefetchPaths.some((path) => path.includes('[teamSlug]')) + ).toBe(false) + expect( + segmentPrefetchPaths.some((path) => path.includes('[project]')) + ).toBe(false) + for (const routePrefix of expectedRoutePrefixes) { + expect( + segmentPrefetchPaths.some((path) => + path.startsWith(`${routePrefix}.segments/`) + ) + ).toBe(true) + } + expect( + segmentPrefetchPaths.every( + (path) => path.includes('.segments/') && path.endsWith('.segment.rsc') + ) + ).toBe(true) + } + + const warmSegmentCache = async () => { + const warmedResponses = [ + ...(await collectSegmentPrefetchResponses('/acme/dashboard')), + ...(await collectSegmentPrefetchResponses('/globex/portal')), + ] + assertValidSegmentResponses(warmedResponses) + } + + const primeProductionShapeRouteCache = async () => { + const acmeDomains = await next.fetch('/acme/dashboard/settings/domains') + const globexDomains = await next.fetch('/globex/portal/settings/domains') + + expect(acmeDomains.status).toBe(200) + expect(globexDomains.status).toBe(200) + } + + const triggerRevalidation = async (mode: RevalidateMode) => { + const revalidateResponse = await next.fetch( + `/api/revalidate-layout?mode=${mode}` + ) + expect(revalidateResponse.status).toBe(200) + expect(await revalidateResponse.json()).toEqual({ + revalidated: true, + mode, + }) + } + + it('keeps dynamic segment params valid before and after time-based revalidation', async () => { + const readRouteMarker = async (path: string, expectedText: string) => { + const browser = await next.browser(path) + const content = await browser.elementByCss('[data-team-project-content]') + const text = await content.text() + await browser.close() + + expect(text).toContain(expectedText) + const markerMatch = text.match(/marker: (\d+)/) + expect(markerMatch).not.toBeNull() + return Number(markerMatch![1]) + } + + const initialAcmeMarker = await readRouteMarker( + '/acme/dashboard', + 'Team project content - team: acme, project: dashboard' + ) + const initialGlobexMarker = await readRouteMarker( + '/globex/portal', + 'Team project content - team: globex, project: portal' + ) + + const initialResponses = [ + ...(await collectSegmentPrefetchResponses('/acme/dashboard')), + ...(await collectSegmentPrefetchResponses('/globex/portal')), + ] + assertValidSegmentResponses(initialResponses) + + let lastAcmeMarker = initialAcmeMarker + let lastGlobexMarker = initialGlobexMarker + + for (let checkIndex = 0; checkIndex < 5; checkIndex++) { + await waitFor(2_000) + + const revalidatedResponses = [ + ...(await collectSegmentPrefetchResponses('/acme/dashboard')), + ...(await collectSegmentPrefetchResponses('/globex/portal')), + ] + assertValidSegmentResponses(revalidatedResponses) + + const revalidatedAcmeMarker = await readRouteMarker( + '/acme/dashboard', + 'Team project content - team: acme, project: dashboard' + ) + const revalidatedGlobexMarker = await readRouteMarker( + '/globex/portal', + 'Team project content - team: globex, project: portal' + ) + + expect(revalidatedAcmeMarker).not.toBe(lastAcmeMarker) + expect(revalidatedGlobexMarker).not.toBe(lastGlobexMarker) + + lastAcmeMarker = revalidatedAcmeMarker + lastGlobexMarker = revalidatedGlobexMarker + } + }) + + it.each([ + 'tag-layout-expireNow', + 'tag-layout-max', + 'tag-layout-legacy', + 'path-root-layout', + 'path-team-layout', + 'path-team-page', + ])( + 'keeps dynamic segment params valid after %s with in-view Link back/forward navigations', + async (mode) => { + await warmSegmentCache() + await triggerRevalidation(mode) + + const navigationResult = + await collectSegmentPrefetchResponsesFromBackForwardNavigation() + + assertValidSegmentResponses(navigationResult.responses) + + expect( + navigationResult.pageErrors.some( + (error) => + error.includes('Minified React error') || + error.includes('not-found') || + error.includes('Invariant') + ) + ).toBe(false) + } + ) + + it.each([ + 'tag-layout-expireNow', + 'tag-layout-max', + 'tag-layout-legacy', + 'server-action-tag-layout-expireNow', + 'server-action-tag-layout-max', + 'server-action-tag-layout-legacy', + ])( + 'keeps dynamic segment params valid when browser-triggered revalidation uses %s', + async (mode) => { + await warmSegmentCache() + + const navigationResult = + await collectSegmentPrefetchResponsesFromBackForwardNavigation(mode) + + assertValidSegmentResponses(navigationResult.responses) + + expect( + navigationResult.pageErrors.some( + (error) => + error.includes('Minified React error') || + error.includes('not-found') || + error.includes('Invariant') + ) + ).toBe(false) + } + ) + + it.each(['tag-layout-expireNow', 'tag-layout-legacy'])( + 'keeps params valid for production route shape /[team]/[project]/settings/domains after %s', + async (mode) => { + await primeProductionShapeRouteCache() + await triggerRevalidation(mode) + + const navigationResult = + await collectSegmentPrefetchResponsesFromProductionShapeNavigation() + + assertValidSegmentResponses(navigationResult.responses, [ + '/acme/dashboard/settings/domains', + '/globex/portal/settings/domains', + ]) + + expect( + navigationResult.pageErrors.some( + (error) => + error.includes('Minified React error') || + error.includes('not-found') || + error.includes('Invariant') + ) + ).toBe(false) + } + ) +}) diff --git a/test/e2e/app-dir/segment-cache/vary-params/pages/api/revalidate.ts b/test/e2e/app-dir/segment-cache/vary-params/pages/api/revalidate.ts new file mode 100644 index 000000000000..0ba199403eb3 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params/pages/api/revalidate.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<{ revalidated: boolean }> +) { + const pathParam = req.query['path'] + + if (!pathParam) { + return res.status(400).json({ revalidated: false }) + } + + const paths = Array.isArray(pathParam) ? pathParam : [pathParam] + + try { + await Promise.all(paths.map((path) => res.revalidate(path))) + return res.status(200).json({ revalidated: true }) + } catch (error) { + console.error('Failed to revalidate paths:', paths, error) + return res.status(500).json({ revalidated: false }) + } +} diff --git a/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts b/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts new file mode 100644 index 000000000000..1826252faa96 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts @@ -0,0 +1,61 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +describe('segment cache - root params segment prefetch', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + if (isNextDev) { + test('prefetching is disabled in dev mode', () => {}) + return + } + + it('does not encode root param placeholders in segment-prefetch responses', async () => { + let act: ReturnType + const segmentPrefetchBodies: Array> = [] + const browser = await next.browser('/root-params', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + p.on('response', (response) => { + const request = response.request() + if (request.headers()['next-router-segment-prefetch']) { + segmentPrefetchBodies.push(response.text().catch(() => '')) + } + }) + }, + }) + + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/aaa"]' + ) + await toggle.click() + }, + { includes: 'Root param page content - param: aaa' } + ) + + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/bbb"]' + ) + await toggle.click() + }, + { includes: 'Root param page content - param: bbb' } + ) + + const settledSegmentPrefetchBodies = await Promise.all( + segmentPrefetchBodies + ) + + expect(settledSegmentPrefetchBodies.length).toBeGreaterThan(0) + expect( + settledSegmentPrefetchBodies.some((body) => + body.includes('%5BrootParam%5D') + ) + ).toBe(false) + }) +}) diff --git a/test/e2e/app-dir/sub-shell-generation-middleware/sub-shell-generation-middleware.test.ts b/test/e2e/app-dir/sub-shell-generation-middleware/sub-shell-generation-middleware.test.ts index 441049880054..a6aa1e17c05a 100644 --- a/test/e2e/app-dir/sub-shell-generation-middleware/sub-shell-generation-middleware.test.ts +++ b/test/e2e/app-dir/sub-shell-generation-middleware/sub-shell-generation-middleware.test.ts @@ -135,7 +135,7 @@ describe('middleware-static-rewrite', () => { it('should revalidate the overview page without replacing it with a 404', async () => { const url = new URL('/my-team', 'http://localhost') - const rsc = computeCacheBustingSearchParam( + const rsc = await computeCacheBustingSearchParam( '1', '/_head', undefined, diff --git a/test/e2e/app-document/rendering.test.ts b/test/e2e/app-document/rendering.test.ts index 80c7f60390bc..b6510e47b0b0 100644 --- a/test/e2e/app-document/rendering.test.ts +++ b/test/e2e/app-document/rendering.test.ts @@ -81,19 +81,35 @@ describe('Document and App - Rendering via HTTP', () => { }) if (isNextDev) { - // This is a workaround to fix https://github.com/vercel/next.js/issues/5860 - // TODO: remove this workaround when https://bugs.webkit.org/show_bug.cgi?id=187726 is fixed. - it('adds a timestamp to link tags with preload attribute to invalidate the cache in dev', async () => { + // The ?ts= timestamp is a workaround for a Safari preload cache bug: + // https://github.com/vercel/next.js/issues/5860 + // https://bugs.webkit.org/show_bug.cgi?id=187726 + // It must only appear on CSS/font resources, not on script tags, because + // the Turbopack runtime reads ASSET_SUFFIX from the executing script's + // query string and would leak it onto image URLs. + it('adds a timestamp only to CSS/font link tags to invalidate the cache in dev', async () => { const $ = await next.render$('/', undefined, { headers: { 'user-agent': 'Safari' }, }) - $('link[rel=preload]').each((index, element) => { + // CSS preload links must have ?ts= for Safari cache busting + $('link[rel=preload][as=style]').each((index, element) => { const href = $(element).attr('href') expect(href).toMatch(/^[^?]+\?ts=\d+$/) }) + // Font preload links must have ?ts= for Safari cache busting + $('link[rel=preload][as=font]').each((index, element) => { + const href = $(element).attr('href') + expect(href).toMatch(/^[^?]+\?ts=\d+$/) + }) + // Script preload links must NOT have ?ts= (Turbopack ASSET_SUFFIX bug) + $('link[rel=preload][as=script]').each((index, element) => { + const src = $(element).attr('href') + expect(src).not.toMatch(/[?&]ts=/) + }) + // Script tags must NOT have ?ts= (Turbopack ASSET_SUFFIX bug) $('script[src]').each((index, element) => { const src = $(element).attr('src') - expect(src).toMatch(/^[^?]+\?ts=\d+$/) + expect(src).not.toMatch(/[?&]ts=/) }) }) } diff --git a/test/e2e/css-data-url-global-pages/data-url-css.d.ts b/test/e2e/css-data-url-global-pages/data-url-css.d.ts new file mode 100644 index 000000000000..2da05b3e4f1a --- /dev/null +++ b/test/e2e/css-data-url-global-pages/data-url-css.d.ts @@ -0,0 +1,2 @@ +// Test-local typing for Turbopack's CSS data URL side-effect import. +declare module 'data:text/css,*' {} diff --git a/test/e2e/define/app/client-component.js b/test/e2e/define/app/client-component.js index 77036e77b0ff..20ea232aedc8 100644 --- a/test/e2e/define/app/client-component.js +++ b/test/e2e/define/app/client-component.js @@ -16,3 +16,23 @@ export function ClientExpr() { ) } + +export function ClientNumber() { + return ( + <> + {typeof MY_NUMBER_VARIABLE === 'number' + ? String(MY_NUMBER_VARIABLE) + : 'not set'} + + ) +} + +export function ClientBoolean() { + return ( + <> + {typeof MY_BOOLEAN_VARIABLE === 'boolean' + ? String(MY_BOOLEAN_VARIABLE) + : 'not set'} + + ) +} diff --git a/test/e2e/define/app/page.js b/test/e2e/define/app/page.js index a732c53173ce..04db6e53d67a 100644 --- a/test/e2e/define/app/page.js +++ b/test/e2e/define/app/page.js @@ -1,5 +1,10 @@ /* eslint-disable no-undef */ -import { ClientValue, ClientExpr } from './client-component' +import { + ClientValue, + ClientExpr, + ClientNumber, + ClientBoolean, +} from './client-component' export default function Page() { return ( @@ -20,6 +25,24 @@ export default function Page() {
  • Client expr:
  • +
  • + Server number:{' '} + {typeof MY_NUMBER_VARIABLE === 'number' + ? String(MY_NUMBER_VARIABLE) + : 'not set'} +
  • +
  • + Client number: +
  • +
  • + Server boolean:{' '} + {typeof MY_BOOLEAN_VARIABLE === 'boolean' + ? String(MY_BOOLEAN_VARIABLE) + : 'not set'} +
  • +
  • + Client boolean: +
  • ) } diff --git a/test/e2e/define/define.test.ts b/test/e2e/define/define.test.ts index d7a967a64f45..d2570e908dec 100644 --- a/test/e2e/define/define.test.ts +++ b/test/e2e/define/define.test.ts @@ -31,6 +31,16 @@ describe('compiler.define', () => { expect(loadedText).toContain('Server expr: barbaz') expect(loadedText).toContain('Client expr: barbaz') }) + + it('should render a number variable on server and client side', async () => { + expect(loadedText).toContain('Server number: 42') + expect(loadedText).toContain('Client number: 42') + }) + + it('should render a boolean variable on server and client side', async () => { + expect(loadedText).toContain('Server boolean: true') + expect(loadedText).toContain('Client boolean: true') + }) }) describe('compiler.defineServer', () => { diff --git a/test/e2e/define/next.config.js b/test/e2e/define/next.config.js index 6c9253dfe794..6dc092c83052 100644 --- a/test/e2e/define/next.config.js +++ b/test/e2e/define/next.config.js @@ -3,6 +3,8 @@ module.exports = { define: { MY_MAGIC_VARIABLE: 'foobar', 'process.env.MY_MAGIC_EXPR': 'barbaz', + MY_NUMBER_VARIABLE: 42, + MY_BOOLEAN_VARIABLE: true, }, defineServer: { MY_SERVER_VARIABLE: 'server', diff --git a/test/e2e/filesystem-cache/filesystem-cache.test.ts b/test/e2e/filesystem-cache/filesystem-cache.test.ts index dece69e81029..283056869860 100644 --- a/test/e2e/filesystem-cache/filesystem-cache.test.ts +++ b/test/e2e/filesystem-cache/filesystem-cache.test.ts @@ -48,6 +48,7 @@ for (const cacheEnabled of [false, true]) { files: __dirname, skipDeployment: true, packageJson: { + packageManager: 'npm@10.9.2', scripts: { build: `${envVars} next build`, dev: `${envVars} next dev`, diff --git a/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts b/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts index dc9927231afe..89059f33b388 100644 --- a/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts +++ b/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts @@ -1,4 +1,4 @@ -import { createNext } from 'e2e-utils' +import { createNext, isNextDev } from 'e2e-utils' import { NextInstance } from 'e2e-utils' import { renderViaHTTP } from 'next-test-utils' @@ -24,8 +24,18 @@ describe('handle-non-hoisted-swc-helpers', () => { } `, }, + packageJson: { + packageManager: 'npm@10.9.2', + scripts: { + build: 'next build', + dev: 'next dev', + start: 'next start', + }, + }, installCommand: 'npm install; mkdir -p node_modules/next/node_modules/@swc; mv node_modules/@swc/helpers node_modules/next/node_modules/@swc/', + buildCommand: 'npm run build', + startCommand: isNextDev ? 'npm run dev' : 'npm run start', dependencies: {}, }) }) diff --git a/test/e2e/middleware-general/test/index.test.ts b/test/e2e/middleware-general/test/index.test.ts index d96b84854a37..4d75e245bf6f 100644 --- a/test/e2e/middleware-general/test/index.test.ts +++ b/test/e2e/middleware-general/test/index.test.ts @@ -14,10 +14,6 @@ describe('Middleware Runtime', () => { const isNodeMiddleware = Boolean(process.env.TEST_NODE_MIDDLEWARE) - if (isNodeMiddleware && (global as any).isNextDeploy) { - return it('should skip deploy for node middleware for now', () => {}) - } - const setup = ({ i18n }: { i18n: boolean }) => { afterAll(async () => { await next.destroy() @@ -108,6 +104,19 @@ describe('Middleware Runtime', () => { } function runTests({ i18n }: { i18n?: boolean }) { + it('should not treat as _next/data request with just header', async () => { + const res = await next.fetch('/redirect-to-somewhere', { + redirect: 'manual', + headers: { + 'x-nextjs-data': '1', + }, + }) + + expect(res.status).toBe(307) + expect(res.headers.get('Location')).toContain('/somewhere') + expect(res.headers.get('x-nextjs-redirect')).toBe(null) + }) + if (isNodeMiddleware) { it('should be able to use node builtins with node runtime', async () => { const res = await next.fetch('/test-node-fs') @@ -752,6 +761,23 @@ describe('Middleware Runtime', () => { `/_next/data/${next.buildId}${i18n ? '/en' : ''}/send-url.json` ) expect(res.headers.get('req-url-path')).toEqual('/send-url') + + if (i18n) { + expect(res.headers.get('req-url-pathname')).toEqual('/send-url') + expect(res.headers.get('req-url-locale')).toEqual('en') + + const defaultLocaleRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/send-url.json` + ) + expect(defaultLocaleRes.headers.get('req-url-path')).toEqual( + '/send-url' + ) + expect(defaultLocaleRes.headers.get('req-url-pathname')).toEqual( + '/send-url' + ) + expect(defaultLocaleRes.headers.get('req-url-locale')).toEqual('en') + } }) it('should keep non data requests in their original shape', async () => { @@ -778,6 +804,18 @@ describe('Middleware Runtime', () => { expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual( `${i18n ? '/en' : ''}/ssr-page-2` ) + + if (i18n) { + const defaultLocaleDataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/ssr-page.json` + ) + const defaultLocaleJson = await defaultLocaleDataRes.json() + expect(defaultLocaleJson.pageProps.message).toEqual('Bye Cruel World') + expect(defaultLocaleDataRes.headers.get('x-nextjs-matched-path')).toBe( + '/en/ssr-page-2' + ) + } }) it(`hard-navigates when the data request failed`, async () => { diff --git a/test/e2e/middleware-matcher/index.test.ts b/test/e2e/middleware-matcher/index.test.ts index ca9ee23f0bfb..deeda042e67c 100644 --- a/test/e2e/middleware-matcher/index.test.ts +++ b/test/e2e/middleware-matcher/index.test.ts @@ -111,6 +111,83 @@ describe('Middleware can set the matcher in its config', () => { }, 'success') }) + if ((global as any).isNextStart) { + it('produces the expected middleware manifest', async () => { + const manifest = JSON.parse( + await next.readFile('.next/server/middleware-manifest.json') + ) + + // Redact volatile fields so the snapshot is stable across builds: + // - `env` values are randomly generated per build (encryption keys, + // preview mode ids, build id). + // - `files` and `entrypoint` paths contain content hashes and may + // differ between webpack and Turbopack. + const normalize = (value: unknown, key?: string): unknown => { + if (key === 'env' && value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, '']) + ) + } + if (key === 'files') return '' + if (key === 'entrypoint') return '' + if (Array.isArray(value)) return value.map((v) => normalize(v)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, normalize(v, k)]) + ) + } + return value + } + + expect(normalize(manifest)).toMatchInlineSnapshot(` + { + "functions": {}, + "middleware": { + "/": { + "assets": [], + "entrypoint": "", + "env": { + "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY": "", + "__NEXT_BUILD_ID": "", + "__NEXT_PREVIEW_MODE_ENCRYPTION_KEY": "", + "__NEXT_PREVIEW_MODE_ID": "", + "__NEXT_PREVIEW_MODE_SIGNING_KEY": "", + }, + "files": "", + "matchers": [ + { + "originalSource": "/", + "regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/(\\/?index|\\/?index\\.json|\\/?index(?:\\.rsc|\\.segments\\/.+\\.segment\\.rsc)))?[\\/#\\?]?$", + }, + { + "originalSource": "/with-middleware/:path*", + "regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/with-middleware(?:\\/((?:[^\\/#\\?]+?)(?:\\/(?:[^\\/#\\?]+?))*))?(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$", + }, + { + "originalSource": "/another-middleware/:path*", + "regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/another-middleware(?:\\/((?:[^\\/#\\?]+?)(?:\\/(?:[^\\/#\\?]+?))*))?(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$", + }, + { + "originalSource": "/_sites/:path((?![^/]*\\.json$)[^/]+$)", + "regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/_sites(?:\\/((?![^/]*\\.json$)[^/]+$))(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$", + }, + ], + "name": "middleware", + "page": "/", + "wasm": [], + }, + }, + "sortedMiddleware": [ + "/", + ], + "version": 3, + } + `) + }) + } + it('should navigate correctly with matchers', async () => { const browser = await webdriver(next.url, '/') await browser.eval('window.beforeNav = 1') @@ -242,6 +319,112 @@ describe('using a single matcher', () => { }) }) +describe.each([ + { title: '' }, + { title: ' and trailingSlash', trailingSlash: true }, +])( + 'using a single matcher with i18n for a non-root route$title', + ({ trailingSlash }) => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/[...route].js': ` + export default function Page({ message }) { + return
    +

    catchall page

    +

    {message}

    +
    + } + + export const getServerSideProps = ({ params, locale }) => ({ + props: { + message: \`(\${locale}) Hello from /\${params.route.join("/")}\` + } + }) + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { + matcher: '/middleware/works' + }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + 'next.config.js': ` + module.exports = { + ${trailingSlash ? 'trailingSlash: true,' : ''} + i18n: { + localeDetection: false, + locales: ['es', 'en'], + defaultLocale: 'en', + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('adds the header for matched paths', async () => { + const res1 = await fetchViaHTTP(next.url, '/middleware/works') + expect(await res1.text()).toContain(`(en) Hello from /middleware/works`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + + const res2 = await fetchViaHTTP(next.url, '/es/middleware/works') + expect(await res2.text()).toContain(`(es) Hello from /middleware/works`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for matched data paths, including the default locale without a prefix', async () => { + const res1 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en/middleware/works.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res1.json()).toMatchObject({ + pageProps: { + message: '(en) Hello from /middleware/works', + }, + }) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + + const res2 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/middleware/works.json` + ) + expect(await res2.json()).toMatchObject({ + pageProps: { + message: '(es) Hello from /middleware/works', + }, + }) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + + const res3 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware/works.json` + ) + expect(await res3.json()).toMatchObject({ + pageProps: { + message: '(en) Hello from /middleware/works', + }, + }) + expect(res3.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does not add the header for an unmatched path', async () => { + const response = await fetchViaHTTP(next.url, '/about/me') + expect(await response.text()).toContain('(en) Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) + } +) + describe('using root matcher', () => { let next: NextInstance beforeAll(async () => { diff --git a/test/e2e/styled-jsx-dynamic/components/DynamicStyled.js b/test/e2e/styled-jsx-dynamic/components/DynamicStyled.js new file mode 100644 index 000000000000..60756a0b9e16 --- /dev/null +++ b/test/e2e/styled-jsx-dynamic/components/DynamicStyled.js @@ -0,0 +1,12 @@ +export default function DynamicStyled({ color }) { + return ( +
    + +

    dynamic styled

    +
    + ) +} diff --git a/test/e2e/styled-jsx-dynamic/components/Footer.js b/test/e2e/styled-jsx-dynamic/components/Footer.js new file mode 100644 index 000000000000..037de0a33603 --- /dev/null +++ b/test/e2e/styled-jsx-dynamic/components/Footer.js @@ -0,0 +1,13 @@ +export default function Footer({ color }) { + return ( +
    + + Footer +
    + ) +} diff --git a/test/e2e/styled-jsx-dynamic/components/Header.js b/test/e2e/styled-jsx-dynamic/components/Header.js new file mode 100644 index 000000000000..48e90ecc0ae2 --- /dev/null +++ b/test/e2e/styled-jsx-dynamic/components/Header.js @@ -0,0 +1,14 @@ +export default function Header({ bg, fg }) { + return ( +
    + + Header +
    + ) +} diff --git a/test/e2e/styled-jsx-dynamic/index.test.ts b/test/e2e/styled-jsx-dynamic/index.test.ts new file mode 100644 index 000000000000..adbd235b296a --- /dev/null +++ b/test/e2e/styled-jsx-dynamic/index.test.ts @@ -0,0 +1,27 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('styled-jsx dynamic styles SSR', () => { + const { next } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + // Dynamic styled-jsx (with interpolated expressions) produces numeric class + // names at runtime via the DJB2 hash in styled-jsx's computeId function. + // This pattern matches production deployments where all jsx class names + // are numeric (e.g. jsx-2267428885) rather than hex (jsx-f36313d9f07883b7). + it('should contain dynamic styled-jsx styles during SSR', async () => { + const html = await next.render('/') + + // Dynamic styled-jsx produces numeric class names at runtime + const numericClasses = html.match(/\bjsx-\d+\b/g) || [] + console.log('Numeric jsx classes:', [...new Set(numericClasses)]) + expect(numericClasses.length).toBeGreaterThan(0) + + // All dynamic styles should be present as inline +
    +
    + +
    +