diff --git a/.github/actions/next-stats-action/package.json b/.github/actions/next-stats-action/package.json index 738cf4ed4cd2..d5e4c5384aba 100644 --- a/.github/actions/next-stats-action/package.json +++ b/.github/actions/next-stats-action/package.json @@ -4,7 +4,6 @@ "dependencies": { "async-sema": "^3.1.0", "execa": "2.0.3", - "fs-extra": "^8.1.0", "get-port": "^5.0.0", "glob": "^7.1.4", "gzip-size": "^5.1.1", @@ -20,7 +19,7 @@ }, "engines": { "node": ">=16.14.0", - "pnpm": "8.7.1" + "pnpm": "8.9.0" }, - "packageManager": "pnpm@8.7.1" + "packageManager": "pnpm@8.9.0" } diff --git a/.github/actions/next-stats-action/src/add-comment.js b/.github/actions/next-stats-action/src/add-comment.js index a87306703404..c65e54236e44 100644 --- a/.github/actions/next-stats-action/src/add-comment.js +++ b/.github/actions/next-stats-action/src/add-comment.js @@ -24,6 +24,8 @@ const shortenLabel = (itemKey) => : itemKey const twoMB = 2 * 1024 * 1024 +const ONE_HUNDRED_BYTES = 100 +const ONE_HUNDRED_MS = 100 module.exports = async function addComment( results = [], @@ -82,24 +84,21 @@ module.exports = async function addComment( // otherwise only show gzip values else if (!isGzipItem && !groupKey.match(gzipIgnoreRegex)) return - if ( - !itemKey.startsWith('buildDuration') || - (isBenchmark && itemKey.match(/req\/sec/)) - ) { - if (typeof mainItemVal === 'number') mainRepoTotal += mainItemVal - if (typeof diffItemVal === 'number') diffRepoTotal += diffItemVal - } - // calculate the change if (mainItemVal !== diffItemVal) { if ( typeof mainItemVal === 'number' && typeof diffItemVal === 'number' ) { - change = round(diffItemVal - mainItemVal, 2) + const roundedValue = round(diffItemVal - mainItemVal, 2) // check if there is still a change after rounding - if (change !== 0) { + if ( + roundedValue !== 0 && + ((prettyType === 'ms' && roundedValue > ONE_HUNDRED_MS) || + (prettyType === 'bytes' && roundedValue > ONE_HUNDRED_BYTES)) + ) { + change = roundedValue const absChange = Math.abs(change) const warnIfNegative = isBenchmark && itemKey.match(/req\/sec/) const warn = warnIfNegative @@ -112,12 +111,22 @@ module.exports = async function addComment( change = `${warn}${change < 0 ? '-' : '+'}${ useRawValue ? absChange : prettify(absChange, prettyType) }` + } else { + change = 'N/A' } } else { change = 'N/A' } } + if ( + (change !== 'N/A' && !itemKey.startsWith('buildDuration')) || + (isBenchmark && itemKey.match(/req\/sec/)) + ) { + if (typeof mainItemVal === 'number') mainRepoTotal += mainItemVal + if (typeof diffItemVal === 'number') diffRepoTotal += diffItemVal + } + groupTable += `| ${ isBenchmark ? itemKey : shortenLabel(itemKey) } | ${mainItemStr} | ${diffItemStr} | ${change} |\n` @@ -169,8 +178,7 @@ module.exports = async function addComment( // add diffs if (result.diffs) { - const diffHeading = '#### Diffs\n' - let diffContent = diffHeading + let diffContent = '' Object.keys(result.diffs).forEach((itemKey) => { const curDiff = result.diffs[itemKey] @@ -187,8 +195,11 @@ module.exports = async function addComment( diffContent += `\n\n` }) - if (diffContent !== diffHeading) { + if (diffContent.length > 0) { + resultContent += `
\n` + resultContent += `Diff details\n\n` resultContent += diffContent + resultContent += `\n
\n\n` } } let increaseDecreaseNote = '' @@ -199,7 +210,7 @@ module.exports = async function addComment( increaseDecreaseNote = ' (Decrease detected ✓)' } - comment += `
\n` + comment += `
\n` comment += `${result.title}${increaseDecreaseNote}\n\n
\n\n` comment += resultContent comment += '
\n' diff --git a/.github/actions/next-stats-action/src/index.js b/.github/actions/next-stats-action/src/index.js index 402c7cfadfcb..a859aaac0cc7 100644 --- a/.github/actions/next-stats-action/src/index.js +++ b/.github/actions/next-stats-action/src/index.js @@ -1,5 +1,6 @@ const path = require('path') -const fs = require('fs-extra') +const fs = require('fs/promises') +const { existsSync } = require('fs') const exec = require('./util/exec') const logger = require('./util/logger') const runConfigs = require('./run') @@ -21,7 +22,7 @@ if (!allowedActions.has(actionInfo.actionName) && !actionInfo.isRelease) { ;(async () => { try { - if (await fs.pathExists(path.join(__dirname, '../SKIP_NEXT_STATS.txt'))) { + if (existsSync(path.join(__dirname, '../SKIP_NEXT_STATS.txt'))) { console.log( 'SKIP_NEXT_STATS.txt file present, exiting stats generation..' ) @@ -100,7 +101,7 @@ if (!allowedActions.has(actionInfo.actionName) && !actionInfo.isRelease) { for (const dir of repoDirs) { logger(`Running initial build for ${dir}`) if (!actionInfo.skipClone) { - const usePnpm = await fs.pathExists(path.join(dir, 'pnpm-lock.yaml')) + const usePnpm = existsSync(path.join(dir, 'pnpm-lock.yaml')) if (!statsConfig.skipInitialInstall) { await exec.spawnPromise( @@ -121,15 +122,13 @@ if (!allowedActions.has(actionInfo.actionName) && !actionInfo.isRelease) { } await fs - .copy( + .cp( path.join(__dirname, '../native'), - path.join(dir, 'packages/next-swc/native') + path.join(dir, 'packages/next-swc/native'), + { recursive: true, force: true } ) .catch(console.error) - console.log(await exec(`ls ${path.join(__dirname, '../native')}`)) - console.log(await exec(`cd ${dir} && ls ${dir}/packages/next-swc/native`)) - logger(`Linking packages in ${dir}`) const isMainRepo = dir === mainRepoDir const pkgPaths = await linkPackages({ diff --git a/.github/actions/next-stats-action/src/prepare/repo-setup.js b/.github/actions/next-stats-action/src/prepare/repo-setup.js index 74fd6ea1fc92..df62cc572340 100644 --- a/.github/actions/next-stats-action/src/prepare/repo-setup.js +++ b/.github/actions/next-stats-action/src/prepare/repo-setup.js @@ -1,16 +1,14 @@ const path = require('path') -const fse = require('fs-extra') -const fs = require('fs') -const fsp = require('fs/promises') +const fs = require('fs/promises') +const { existsSync } = require('fs') const exec = require('../util/exec') -const { remove } = require('fs-extra') const logger = require('../util/logger') const execa = require('execa') module.exports = (actionInfo) => { return { async cloneRepo(repoPath = '', dest = '', branch = '', depth = '20') { - await remove(dest) + await fs.rm(dest, { recursive: true, force: true }) await exec( `git clone ${actionInfo.gitRoot}${repoPath} --single-branch --branch ${branch} --depth=${depth} ${dest}` ) @@ -72,7 +70,7 @@ module.exports = (actionInfo) => { let pkgs try { - pkgs = await fsp.readdir(path.join(repoDir, 'packages')) + pkgs = await fs.readdir(path.join(repoDir, 'packages')) } catch (err) { if (err.code === 'ENOENT') { require('console').log('no packages to link') @@ -87,8 +85,8 @@ module.exports = (actionInfo) => { const packedPkgPath = path.join(pkgPath, `${pkg}-packed.tgz`) const pkgDataPath = path.join(pkgPath, 'package.json') - if (fs.existsSync(pkgDataPath)) { - const pkgData = JSON.parse(await fsp.readFile(pkgDataPath)) + if (existsSync(pkgDataPath)) { + const pkgData = JSON.parse(await fs.readFile(pkgDataPath)) const { name } = pkgData pkgDatas.set(name, { @@ -122,7 +120,7 @@ module.exports = (actionInfo) => { pkgData.files.push('native') try { - const swcBinariesDirContents = await fsp.readdir( + const swcBinariesDirContents = await fs.readdir( path.join(pkgPath, 'native') ) require('console').log( @@ -155,7 +153,7 @@ module.exports = (actionInfo) => { } } - await fsp.writeFile( + await fs.writeFile( pkgDataPath, JSON.stringify(pkgData, null, 2), 'utf8' @@ -186,9 +184,9 @@ module.exports = (actionInfo) => { 'disabled-native-gitignore' ) - await fsp.rename(nativeGitignorePath, renamedGitignorePath) + await fs.rename(nativeGitignorePath, renamedGitignorePath) cleanup = async () => { - await fsp.rename(renamedGitignorePath, nativeGitignorePath) + await fs.rename(renamedGitignorePath, nativeGitignorePath) } } @@ -201,7 +199,7 @@ module.exports = (actionInfo) => { }) return Promise.all([ - fsp.rename(path.resolve(pkgPath, stdout.trim()), packedPkgPath), + fs.rename(path.resolve(pkgPath, stdout.trim()), packedPkgPath), cleanup?.(), ]) } diff --git a/.github/actions/next-stats-action/src/run/collect-diffs.js b/.github/actions/next-stats-action/src/run/collect-diffs.js index 3440d8066be4..c850fb843ab9 100644 --- a/.github/actions/next-stats-action/src/run/collect-diffs.js +++ b/.github/actions/next-stats-action/src/run/collect-diffs.js @@ -1,5 +1,6 @@ const path = require('path') -const fs = require('fs-extra') +const fs = require('fs/promises') +const { existsSync } = require('fs') const exec = require('../util/exec') const glob = require('../util/glob') const logger = require('../util/logger') @@ -12,15 +13,17 @@ module.exports = async function collectDiffs( if (initial) { logger('Setting up directory for diffing') // set-up diffing directory - await fs.remove(diffingDir) - await fs.mkdirp(diffingDir) + await fs.rm(diffingDir, { recursive: true, force: true }) + await fs.mkdir(diffingDir, { recursive: true }) await exec(`cd ${diffingDir} && git init`) } else { // remove any previous files in case they won't be overwritten const toRemove = await glob('!(.git)', { cwd: diffingDir, dot: true }) await Promise.all( - toRemove.map((file) => fs.remove(path.join(diffingDir, file))) + toRemove.map((file) => + fs.rm(path.join(diffingDir, file), { recursive: true, force: true }) + ) ) } const diffs = {} @@ -40,7 +43,7 @@ module.exports = async function collectDiffs( const absPath = path.join(statsAppDir, file) const diffDest = path.join(diffingDir, file) - await fs.copy(absPath, diffDest) + await fs.cp(absPath, diffDest, { recursive: true, force: true }) } if (curFiles.length > 0) { @@ -75,7 +78,7 @@ module.exports = async function collectDiffs( for (const line of renamedFiles) { const [, prev, cur] = line.split('\t') - await fs.move(path.join(diffingDir, cur), path.join(diffingDir, prev)) + await fs.rename(path.join(diffingDir, cur), path.join(diffingDir, prev)) diffs._renames.push({ prev, cur, @@ -91,7 +94,7 @@ module.exports = async function collectDiffs( for (const file of changedFiles) { const fileKey = path.basename(file) - const hasFile = await fs.exists(path.join(diffingDir, file)) + const hasFile = existsSync(path.join(diffingDir, file)) if (!hasFile) { diffs[fileKey] = 'deleted' @@ -103,7 +106,7 @@ module.exports = async function collectDiffs( `cd ${diffingDir} && git diff --minimal HEAD ${file}` ) stdout = (stdout.split(file).pop() || '').trim() - if (stdout.length > 0) { + if (stdout.length > 0 && !isLikelyHashOrIDChange(stdout)) { diffs[fileKey] = stdout } } catch (err) { @@ -114,3 +117,48 @@ module.exports = async function collectDiffs( } return diffs } + +function isLikelyHashOrIDChange(diff) { + const lines = diff.split('\n') + let additions = [] + let deletions = [] + + // Separate additions and deletions + for (const line of lines) { + if (line.startsWith('+')) { + additions.push(line.substring(1).split(/\b/)) + } else if (line.startsWith('-')) { + deletions.push(line.substring(1).split(/\b/)) + } + } + + // If the number of additions and deletions is different, it's not a hash or ID change + if (additions.length !== deletions.length) { + return false + } + + // Compare each addition with each deletion + for (let i = 0; i < additions.length; i++) { + const additionTokens = additions[i] + const deletionTokens = deletions[i] + + // Identify differing tokens + const differingTokens = additionTokens.filter( + (token, index) => token !== deletionTokens[index] + ) + + // Analyze differing tokens + for (const token of differingTokens) { + const isLikelyHash = /^[a-f0-9]+$/.test(token) + const isLikelyID = /^[0-9]+$/.test(token) + // this is most likely noise because some path include the repo name, which can be main or diff + const isLikelyNoise = ['main', 'diff'].includes(token) + + if (!isLikelyHash && !isLikelyID && !isLikelyNoise) { + return false + } + } + } + + return true +} diff --git a/.github/actions/next-stats-action/src/run/collect-stats.js b/.github/actions/next-stats-action/src/run/collect-stats.js index f1ef1eb98b1d..8c54fca65e42 100644 --- a/.github/actions/next-stats-action/src/run/collect-stats.js +++ b/.github/actions/next-stats-action/src/run/collect-stats.js @@ -1,5 +1,5 @@ const path = require('path') -const fs = require('fs-extra') +const fs = require('fs/promises') const getPort = require('get-port') const fetch = require('node-fetch') const glob = require('../util/glob') @@ -84,7 +84,7 @@ module.exports = async function collectStats( if (hasPagesToFetch) { const fetchedPagesDir = path.join(curDir, 'fetched-pages') - await fs.mkdirp(fetchedPagesDir) + await fs.mkdir(fetchedPagesDir, { recursive: true }) for (let url of runConfig.pagesToFetch) { url = url.replace('$PORT', port) diff --git a/.github/actions/next-stats-action/src/run/get-dir-size.js b/.github/actions/next-stats-action/src/run/get-dir-size.js index aa16e519382e..c8e42d463111 100644 --- a/.github/actions/next-stats-action/src/run/get-dir-size.js +++ b/.github/actions/next-stats-action/src/run/get-dir-size.js @@ -1,5 +1,5 @@ const path = require('path') -const fs = require('fs-extra') +const fs = require('fs/promises') // getDirSize recursively gets size of all files in a directory async function getDirSize(dir, ctx = { size: 0 }) { diff --git a/.github/actions/next-stats-action/src/run/index.js b/.github/actions/next-stats-action/src/run/index.js index afc647d399be..66fea2ab2d2b 100644 --- a/.github/actions/next-stats-action/src/run/index.js +++ b/.github/actions/next-stats-action/src/run/index.js @@ -1,5 +1,5 @@ const path = require('path') -const fs = require('fs-extra') +const fs = require('fs/promises') const glob = require('../util/glob') const exec = require('../util/exec') const logger = require('../util/logger') @@ -36,8 +36,8 @@ async function runConfigs( const curStatsAppPath = path.join(diffRepoDir, relativeStatsAppDir) // clean statsAppDir - await fs.remove(statsAppDir) - await fs.copy(curStatsAppPath, statsAppDir) + await fs.rm(statsAppDir, { recursive: true, force: true }) + await fs.cp(curStatsAppPath, statsAppDir, { recursive: true }) logger(`Copying ${curStatsAppPath} ${statsAppDir}`) @@ -70,7 +70,7 @@ async function runConfigs( ? result.replace(/(\.|-)[0-9a-f]{16}(\.|-)/g, '$1HASH$2') : rename.dest if (result === dest) continue - await fs.move( + await fs.rename( path.join(statsAppDir, result), path.join(statsAppDir, dest) ) @@ -172,7 +172,10 @@ async function runConfigs( } async function linkPkgs(pkgDir = '', pkgPaths) { - await fs.remove(path.join(pkgDir, 'node_modules')) + await fs.rm(path.join(pkgDir, 'node_modules'), { + recursive: true, + force: true, + }) const pkgJsonPath = path.join(pkgDir, 'package.json') const pkgData = require(pkgJsonPath) diff --git a/.github/pnpm-lock.yaml b/.github/pnpm-lock.yaml index 1781d02be48a..76a07e96e5db 100644 --- a/.github/pnpm-lock.yaml +++ b/.github/pnpm-lock.yaml @@ -33,9 +33,6 @@ importers: execa: specifier: 2.0.3 version: 2.0.3 - fs-extra: - specifier: ^8.1.0 - version: 8.1.0 get-port: specifier: ^5.0.0 version: 5.1.1 @@ -480,15 +477,6 @@ packages: mime-types: 2.1.35 dev: false - /fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - dev: false - /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: false @@ -522,10 +510,6 @@ packages: path-is-absolute: 1.0.1 dev: false - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: false - /gray-matter@4.0.2: resolution: {integrity: sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==} engines: {node: '>=6.0'} @@ -693,12 +677,6 @@ packages: esprima: 4.0.1 dev: false - /jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - optionalDependencies: - graceful-fs: 4.2.11 - dev: false - /kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -1135,11 +1113,6 @@ packages: resolution: {integrity: sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==} dev: false - /universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - dev: false - /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index 93648a7ae05c..0547c39f95d7 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -463,54 +463,6 @@ jobs: VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }} DEPLOY_ENVIRONMENT: production - testDeployE2E: - name: E2E (deploy) - runs-on: ubuntu-latest - needs: [publishRelease] - env: - NEXT_TELEMETRY_DISABLED: 1 - VERCEL_TEST_TOKEN: ${{ secrets.VERCEL_TEST_TOKEN }} - VERCEL_TEST_TEAM: vtest314-next-e2e-tests - DATADOG_API_KEY: ${{ secrets.DATA_DOG_API_KEY }} - steps: - - uses: actions/cache@v3 - timeout-minutes: 5 - id: restore-build - with: - path: ./* - key: ${{ github.sha }}-${{ github.run_number }} - - - run: npm i -g vercel@latest - - - uses: actions/download-artifact@v3 - with: - name: next-swc-binaries - path: packages/next-swc/native - - - run: RESET_VC_PROJECT=true node scripts/reset-vercel-project.mjs - name: Reset test project - - - run: docker run --rm -v $(pwd):/work mcr.microsoft.com/playwright:v1.35.1-jammy /bin/bash -c "cd /work && NODE_VERSION=${{ env.NODE_LTS_VERSION }} ./scripts/setup-node.sh && corepack enable > /dev/null && DATADOG_TRACE_NEXTJS_TEST=TRUE DATADOG_API_KEY=${DATADOG_API_KEY} DD_ENV=ci VERCEL_TEST_TOKEN=${{ secrets.VERCEL_TEST_TOKEN }} VERCEL_TEST_TEAM=vtest314-next-e2e-tests NEXT_TEST_JOB=1 NEXT_TEST_MODE=deploy TEST_TIMINGS_TOKEN=${{ secrets.TEST_TIMINGS_TOKEN }} NEXT_TEST_CONTINUE_ON_ERROR=1 xvfb-run node run-tests.js --type e2e >> /proc/1/fd/1" - name: Run test/e2e (deploy) - - - name: Upload test trace - if: always() - uses: actions/upload-artifact@v3 - with: - name: test-trace - if-no-files-found: ignore - retention-days: 2 - path: | - test/traces - - - name: Upload test trace to datadog - continue-on-error: true - run: | - ls -al ./test - npm install -g junit-report-merger@6.0.2 @datadog/datadog-ci@2.14.0 @aws-sdk/property-provider@3 - jrm ./nextjs-test-result-junit.xml "test/test-junit-report/**/*.xml" - DD_ENV=ci datadog-ci junit upload --tags test.type:nextjs_deploy_e2e --service nextjs ./nextjs-test-result-junit.xml - releaseStats: name: Release Stats runs-on: diff --git a/.github/workflows/test_e2e_deploy.yml b/.github/workflows/test_e2e_deploy.yml new file mode 100644 index 000000000000..0d86aa03f2f6 --- /dev/null +++ b/.github/workflows/test_e2e_deploy.yml @@ -0,0 +1,80 @@ +name: test-e2e-deploy + +on: + schedule: + # run every day at midnight + - cron: '0 0 * * *' + # allow triggering manually as well + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + VERCEL_TEST_TOKEN: ${{ secrets.VERCEL_TEST_TOKEN }} + VERCEL_TEST_TEAM: vtest314-next-e2e-tests + DATADOG_API_KEY: ${{ secrets.DATA_DOG_API_KEY }} + NAPI_CLI_VERSION: 2.16.2 + TURBO_VERSION: 1.10.9 + NODE_MAINTENANCE_VERSION: 16 + NODE_LTS_VERSION: 18 + CARGO_PROFILE_RELEASE_LTO: 'true' + TURBO_TEAM: 'vercel' + TURBO_REMOTE_ONLY: 'true' + TEST_TIMINGS_TOKEN: ${{ secrets.TEST_TIMINGS_TOKEN }} + NEXT_TELEMETRY_DISABLED: 1 + # we build a dev binary for use in CI so skip downloading + # canary next-swc binaries in the monorepo + NEXT_SKIP_NATIVE_POSTINSTALL: 1 + + strategy: + fail-fast: false + matrix: + group: [1, 2] + + steps: + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_LTS_VERSION }} + check-latest: true + - run: corepack enable + + - uses: actions/checkout@v3 + with: + fetch-depth: 25 + + - run: pnpm install + + - run: pnpm run build + + - run: npm i -g vercel@latest + + - uses: actions/download-artifact@v3 + with: + name: next-swc-binaries + path: packages/next-swc/native + + - run: RESET_VC_PROJECT=true node scripts/reset-vercel-project.mjs + name: Reset test project + + - run: docker run --rm -v $(pwd):/work mcr.microsoft.com/playwright:v1.35.1-jammy /bin/bash -c "cd /work && NODE_VERSION=${{ env.NODE_LTS_VERSION }} ./scripts/setup-node.sh && corepack enable > /dev/null && DATADOG_TRACE_NEXTJS_TEST=TRUE DATADOG_API_KEY=${DATADOG_API_KEY} DD_ENV=ci VERCEL_TEST_TOKEN=${{ secrets.VERCEL_TEST_TOKEN }} VERCEL_TEST_TEAM=vtest314-next-e2e-tests NEXT_TEST_JOB=1 NEXT_TEST_MODE=deploy TEST_TIMINGS_TOKEN=${{ secrets.TEST_TIMINGS_TOKEN }} NEXT_TEST_CONTINUE_ON_ERROR=1 xvfb-run node run-tests.js --type e2e --timings -g ${{ matrix.group }}/2 -c 2 >> /proc/1/fd/1" + name: Run test/e2e (deploy) + + - name: Upload test trace + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-trace + if-no-files-found: ignore + retention-days: 2 + path: | + test/traces + + - name: Upload test trace to datadog + continue-on-error: true + run: | + ls -al ./test + npm install -g junit-report-merger@6.0.2 @datadog/datadog-ci@2.14.0 @aws-sdk/property-provider@3 + jrm ./nextjs-test-result-junit.xml "test/test-junit-report/**/*.xml" + DD_ENV=ci datadog-ci junit upload --tags test.type:nextjs_deploy_e2e --service nextjs ./nextjs-test-result-junit.xml diff --git a/.github/workflows/trigger_release.yml b/.github/workflows/trigger_release.yml index b79df5057f19..6faec5b1c37e 100644 --- a/.github/workflows/trigger_release.yml +++ b/.github/workflows/trigger_release.yml @@ -1,4 +1,8 @@ on: + schedule: + # run every day at 23:15 + - cron: '15 23 * * *' + workflow_dispatch: inputs: releaseType: @@ -38,7 +42,7 @@ jobs: # canary next-swc binaries in the monorepo NEXT_SKIP_NATIVE_POSTINSTALL: 1 - environment: release-${{ github.event.inputs.releaseType }} + environment: release-${{ github.event.inputs.releaseType || 'canary' }} steps: - name: Setup node uses: actions/setup-node@v3 @@ -73,6 +77,6 @@ jobs: - run: pnpm run build - - run: node ./scripts/start-release.js --release-type ${{ github.event.inputs.releaseType }} --semver-type ${{ github.event.inputs.semverType }} + - run: node ./scripts/start-release.js --release-type ${{ github.event.inputs.releaseType || 'canary' }} --semver-type ${{ github.event.inputs.semverType }} env: RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4c5c09be61c4..21603e2d9723 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -111,76 +111,76 @@ stages: env: NEXT_TEST_MODE: 'dev' - # - job: test_e2e_dev - # pool: - # vmImage: 'windows-2019' - # steps: - # - task: NodeTool@0 - # inputs: - # versionSpec: $(node_16_version) - # displayName: 'Install Node.js' - - # - bash: | - # node scripts/run-for-change.js --not --type docs --exec echo "##vso[task.setvariable variable=isDocsOnly]No" - # displayName: 'Check Docs Only Change' - - # - script: corepack enable - # condition: eq(variables['isDocsOnly'], 'No') - # displayName: 'Enable Corepack' - - # - script: pnpm config set store-dir $(PNPM_CACHE_FOLDER) - # condition: eq(variables['isDocsOnly'], 'No') - - # - script: pnpm store path - # condition: eq(variables['isDocsOnly'], 'No') - - # - script: pnpm install && pnpm run build - # condition: eq(variables['isDocsOnly'], 'No') - # displayName: 'Install and build' - - # - script: npx playwright@1.35.1 install chromium - # condition: eq(variables['isDocsOnly'], 'No') - - # - script: | - # node run-tests.js -c 1 --debug test/e2e/app-dir/app/index.test.ts - # condition: eq(variables['isDocsOnly'], 'No') - # displayName: 'Run tests (E2E Development)' - # env: - # NEXT_TEST_MODE: 'dev' - - # - job: test_e2e_prod - # pool: - # vmImage: 'windows-2019' - # steps: - # - task: NodeTool@0 - # inputs: - # versionSpec: $(node_16_version) - # displayName: 'Install Node.js' - - # - bash: | - # node scripts/run-for-change.js --not --type docs --exec echo "##vso[task.setvariable variable=isDocsOnly]No" - # displayName: 'Check Docs Only Change' - - # - script: corepack enable - # condition: eq(variables['isDocsOnly'], 'No') - # displayName: 'Enable Corepack' - - # - script: pnpm config set store-dir $(PNPM_CACHE_FOLDER) - # condition: eq(variables['isDocsOnly'], 'No') - - # - script: pnpm store path - # condition: eq(variables['isDocsOnly'], 'No') - - # - script: pnpm install && pnpm run build - # condition: eq(variables['isDocsOnly'], 'No') - # displayName: 'Install and build' - - # - script: npx playwright@1.35.1 install chromium - # condition: eq(variables['isDocsOnly'], 'No') - - # - script: | - # node run-tests.js -c 1 --debug test/e2e/app-dir/app/index.test.ts - # condition: eq(variables['isDocsOnly'], 'No') - # displayName: 'Run tests (E2E Production)' - # env: - # NEXT_TEST_MODE: 'start' + - job: test_e2e_dev + pool: + vmImage: 'windows-2019' + steps: + - task: NodeTool@0 + inputs: + versionSpec: $(node_16_version) + displayName: 'Install Node.js' + + - bash: | + node scripts/run-for-change.js --not --type docs --exec echo "##vso[task.setvariable variable=isDocsOnly]No" + displayName: 'Check Docs Only Change' + + - script: corepack enable + condition: eq(variables['isDocsOnly'], 'No') + displayName: 'Enable Corepack' + + - script: pnpm config set store-dir $(PNPM_CACHE_FOLDER) + condition: eq(variables['isDocsOnly'], 'No') + + - script: pnpm store path + condition: eq(variables['isDocsOnly'], 'No') + + - script: pnpm install && pnpm run build + condition: eq(variables['isDocsOnly'], 'No') + displayName: 'Install and build' + + - script: npx playwright@1.35.1 install chromium + condition: eq(variables['isDocsOnly'], 'No') + + - script: | + node run-tests.js -c 1 --debug test/e2e/app-dir/app/index.test.ts test/e2e/app-dir/app-edge/app-edge.test.ts + condition: eq(variables['isDocsOnly'], 'No') + displayName: 'Run tests (E2E Development)' + env: + NEXT_TEST_MODE: 'dev' + + - job: test_e2e_prod + pool: + vmImage: 'windows-2019' + steps: + - task: NodeTool@0 + inputs: + versionSpec: $(node_16_version) + displayName: 'Install Node.js' + + - bash: | + node scripts/run-for-change.js --not --type docs --exec echo "##vso[task.setvariable variable=isDocsOnly]No" + displayName: 'Check Docs Only Change' + + - script: corepack enable + condition: eq(variables['isDocsOnly'], 'No') + displayName: 'Enable Corepack' + + - script: pnpm config set store-dir $(PNPM_CACHE_FOLDER) + condition: eq(variables['isDocsOnly'], 'No') + + - script: pnpm store path + condition: eq(variables['isDocsOnly'], 'No') + + - script: pnpm install && pnpm run build + condition: eq(variables['isDocsOnly'], 'No') + displayName: 'Install and build' + + - script: npx playwright@1.35.1 install chromium + condition: eq(variables['isDocsOnly'], 'No') + + - script: | + node run-tests.js -c 1 --debug test/e2e/app-dir/app/index.test.ts test/e2e/app-dir/app-edge/app-edge.test.ts + condition: eq(variables['isDocsOnly'], 'No') + displayName: 'Run tests (E2E Production)' + env: + NEXT_TEST_MODE: 'start' diff --git a/contributing/repository/linting.md b/contributing/repository/linting.md index 513797ce7e28..30535fdd6d0c 100644 --- a/contributing/repository/linting.md +++ b/contributing/repository/linting.md @@ -14,7 +14,7 @@ If you get errors, you can run the ESLint and Prettier auto-fix using: pnpm lint-fix ``` -Not all rules can be auto-fixed, those require manual changes. +Not all rules can be auto-fixed, some require manual changes. If you get a warning by alex, follow the instructions to correct the language. diff --git a/docs/02-app/02-api-reference/05-next-config-js/optimizePackageImports.mdx b/docs/02-app/02-api-reference/05-next-config-js/optimizePackageImports.mdx index 2d1a5c1f538c..7f33802a0c77 100644 --- a/docs/02-app/02-api-reference/05-next-config-js/optimizePackageImports.mdx +++ b/docs/02-app/02-api-reference/05-next-config-js/optimizePackageImports.mdx @@ -1,6 +1,6 @@ --- title: optimizePackageImports -description: +description: API Reference for optmizedPackageImports Next.js Config Option --- {/* The content of this doc is shared between the app and pages router. 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/03-pages/01-building-your-application/01-routing/02-dynamic-routes.mdx b/docs/03-pages/01-building-your-application/01-routing/02-dynamic-routes.mdx index 8dd5ac3471b2..2216b0bc6912 100644 --- a/docs/03-pages/01-building-your-application/01-routing/02-dynamic-routes.mdx +++ b/docs/03-pages/01-building-your-application/01-routing/02-dynamic-routes.mdx @@ -13,7 +13,7 @@ When you don't know the exact segment names ahead of time and want to create rou ## Convention -A Dynamic Segment can be created by wrapping a folder's name in square brackets: `[folderName]`. For example, `[id]` or `[slug]`. +A Dynamic Segment can be created by wrapping a file or folder name in square brackets: `[segmentName]`. For example, `[id]` or `[slug]`. Dynamic Segments can be accessed from [`useRouter`](/docs/pages/api-reference/functions/use-router). @@ -38,7 +38,7 @@ export default function Page() { ## Catch-all Segments -Dynamic Segments can be extended to **catch-all** subsequent segments by adding an ellipsis inside the brackets `[...folderName]`. +Dynamic Segments can be extended to **catch-all** subsequent segments by adding an ellipsis inside the brackets `[...segmentName]`. For example, `pages/shop/[...slug].js` will match `/shop/clothes`, but also `/shop/clothes/tops`, `/shop/clothes/tops/t-shirts`, and so on. @@ -50,7 +50,7 @@ For example, `pages/shop/[...slug].js` will match `/shop/clothes`, but also `/sh ## Optional Catch-all Segments -Catch-all Segments can be made **optional** by including the parameter in double square brackets: `[[...folderName]]`. +Catch-all Segments can be made **optional** by including the parameter in double square brackets: `[[...segmentName]]`. For example, `pages/shop/[[...slug]].js` will **also** match `/shop`, in addition to `/shop/clothes`, `/shop/clothes/tops`, `/shop/clothes/tops/t-shirts`. diff --git a/docs/03-pages/01-building-your-application/01-routing/07-api-routes.mdx b/docs/03-pages/01-building-your-application/01-routing/07-api-routes.mdx index 5804e6d3cdfd..5c2b728845b1 100644 --- a/docs/03-pages/01-building-your-application/01-routing/07-api-routes.mdx +++ b/docs/03-pages/01-building-your-application/01-routing/07-api-routes.mdx @@ -217,7 +217,10 @@ The following example sends a JSON response with the status code `200` (`OK`) an ```ts filename="pages/api/hello.ts" switcher import type { NextApiRequest, NextApiResponse } from 'next' -export default function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { try { const result = await someAsyncOperation() res.status(200).json({ result }) @@ -228,7 +231,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { ``` ```js filename="pages/api/hello.js" switcher -export default function handler(req, res) { +export default async function handler(req, res) { try { const result = await someAsyncOperation() res.status(200).json({ result }) @@ -247,7 +250,10 @@ The following example sends a HTTP response with the status code `200` (`OK`) an ```ts filename="pages/api/hello.ts" switcher import type { NextApiRequest, NextApiResponse } from 'next' -export default function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { try { const result = await someAsyncOperation() res.status(200).send({ result }) @@ -258,7 +264,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { ``` ```js filename="pages/api/hello.js" switcher -export default function handler(req, res) { +export default async function handler(req, res) { try { const result = await someAsyncOperation() res.status(200).send({ result }) @@ -277,7 +283,10 @@ The following example redirects the client to the `/` path if the form is succes ```ts filename="pages/api/hello.ts" switcher import type { NextApiRequest, NextApiResponse } from 'next' -export default function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { const { name, message } = req.body try { @@ -290,7 +299,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { ``` ```js filename="pages/api/hello.js" switcher -export default function handler(req, res) { +export default async function handler(req, res) { const { name, message } = req.body try { diff --git a/docs/03-pages/01-building-your-application/03-data-fetching/02-get-static-paths.mdx b/docs/03-pages/01-building-your-application/03-data-fetching/02-get-static-paths.mdx index 4d00624c3f98..77e74f5ead92 100644 --- a/docs/03-pages/01-building-your-application/03-data-fetching/02-get-static-paths.mdx +++ b/docs/03-pages/01-building-your-application/03-data-fetching/02-get-static-paths.mdx @@ -1,6 +1,6 @@ --- title: getStaticPaths -description: Fetch data and generate static pages with `getStaticProps`. Learn more about this API for data fetching in Next.js. +description: Fetch data and generate static pages with `getStaticPaths`. Learn more about this API for data fetching in Next.js. --- If a page has [Dynamic Routes](/docs/pages/building-your-application/routing/dynamic-routes) and uses `getStaticProps`, it needs to define a list of paths to be statically generated. diff --git a/docs/05-community/01-contribution-guide.mdx b/docs/05-community/01-contribution-guide.mdx index 9e51bb61be50..199a84797057 100644 --- a/docs/05-community/01-contribution-guide.mdx +++ b/docs/05-community/01-contribution-guide.mdx @@ -111,7 +111,7 @@ The following fields are **required**: ```yaml filename="required-fields.mdx" --- -tile: Page Title +title: Page Title description: Page Description --- ``` diff --git a/errors/large-page-data.mdx b/errors/large-page-data.mdx index aec886bdfe16..a3c91e075859 100644 --- a/errors/large-page-data.mdx +++ b/errors/large-page-data.mdx @@ -13,7 +13,7 @@ Reduce the amount of data returned from `getStaticProps`, `getServerSideProps`, To inspect the props passed to your page, you can inspect the below element's content in your browser devtools: ```bash filename="Terminal" -document.getElementById("__NEXT_DATA__").text +JSON.parse(document.getElementById("__NEXT_DATA__").textContent) ``` ## Useful Links diff --git a/examples/with-fauna/README.md b/examples/with-fauna/README.md index 0bb3a8b157e4..651367bfe666 100644 --- a/examples/with-fauna/README.md +++ b/examples/with-fauna/README.md @@ -1,6 +1,6 @@ -# Fauna GraphQL Guestbook Starter +# Fauna Guestbook Starter -This Guestbook Single-Page Application (SPA) example shows you how to use [Fauna's GraphQL endpoint](https://docs.fauna.com/fauna/current/api/graphql/) in your Next.js project. +This Guestbook Application example shows you how to use [Fauna](https://docs.fauna.com/) in your Next.js project. ## Deploy your own @@ -8,10 +8,6 @@ Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_mediu [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-fauna&project-name=fauna-nextjs-guestbook&repository-name=fauna-nextjs-guestbook&demo-title=Next.js%20Fauna%20Guestbook%20App&demo-description=A%20simple%20guestbook%20application%20built%20with%20Next.js%20and%20Fauna&integration-ids=oac_Erlbqm8Teb1y4WhioE3r2utY) -## Why Fauna - -By importing a `.gql` or `.graphql` schema into Fauna ([see our sample schema file](./schema.gql)), Fauna will generate required Indexes and GraphQL resolvers for you -- hands free 👐 ([some limitations exist](https://docs.fauna.com/fauna/current/api/graphql/#limitations)). - ## How to use Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: @@ -26,17 +22,12 @@ pnpm create next-app --example with-fauna with-fauna-app You can start with this template [using `create-next-app`](#using-create-next-app) or by [downloading the repository manually](#download-manually). -To use a live Fauna database, create a database at [dashboard.fauna.com](https://dashboard.fauna.com/) and generate an admin token by going to the **Security** tab on the left and then click **New Key**. Give the new key a name and select the 'Admin' Role. Copy the token since the setup script will ask for it. Do not use it in the frontend, it has superpowers which you don't want to give to your users. - -### Setting Up Your Schema - -The Next.js and Fauna example includes a setup script (`npm run setup`). After providing your admin token, the script will: +### Setting Up Your Fauna Database -- **Import your GraphQL schema:** Fauna automatically sets up collections and indexes to support your queries. You can view these in your [project dashboard](https://dashboard.fauna.com/) under **GraphQL**. -- **Create an index and function:** The script will create a GraphQL resolver that uses [User-defined functions](https://docs.fauna.com/fauna/current/api/graphql/functions?lang=javascript) based on a sorting index. -- **Create a scoped token:** This token is for use on the client side. The admin key can be used on the server side. +Head over to [Fauna Dashboard](https://dashboard.fauna.com/) and create a new database. You can name it whatever you want, but for this example, we'll use `nextjs-guestbook`. Next, create a new collection called `Entry` in your new database. +Finally create a new database access key to connect to your database. -After the script completes, a `.env.local` [file](https://nextjs.org/docs/basic-features/environment-variables) will be created for you with the newly generated client token assigned to an Environment Variable. +Watch [this video](https://www.youtube.com/watch?v=8YJcG2fUPyE&t=43s&ab_channel=FaunaInc.) to learn how to connect to your database. ### Run locally diff --git a/examples/with-fauna/actions/entry.ts b/examples/with-fauna/actions/entry.ts new file mode 100644 index 000000000000..3f69f7284de0 --- /dev/null +++ b/examples/with-fauna/actions/entry.ts @@ -0,0 +1,22 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createEntry } from '@/lib/fauna' + +export async function createEntryAction(prevState: any, formData: FormData) { + const name = formData.get('name') as string + const message = formData.get('message') as string + try { + await createEntry(name, message) + revalidatePath('/') + return { + successMessage: 'Thank you for signing the guest book', + errorMessage: null, + } + } catch (error) { + return { + successMessage: null, + errorMessage: 'Something went wrong. Please try again', + } + } +} diff --git a/examples/with-fauna/app/globals.css b/examples/with-fauna/app/globals.css new file mode 100644 index 000000000000..b5c61c956711 --- /dev/null +++ b/examples/with-fauna/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/with-fauna/app/guestbook-page.tsx b/examples/with-fauna/app/guestbook-page.tsx new file mode 100644 index 000000000000..553a61489a49 --- /dev/null +++ b/examples/with-fauna/app/guestbook-page.tsx @@ -0,0 +1,52 @@ +import cn from 'classnames' +import formatDate from 'date-fns/format' +import EntryForm from '@/components/EntryForm' +import { EntryType } from './page' + +const EntryItem = ({ entry }: { entry: EntryType }) => ( +
+
{entry.message}
+
+

{entry.name}

+ / +

+ {formatDate( + new Date(entry.createdAt.isoString), + "d MMM yyyy 'at' h:mm bb" + )} +

+
+
+) + +export default async function GuestbookPage({ + entries, +}: { + entries: EntryType[] +}) { + return ( +
+
+
+ Sign the Guestbook +
+

+ Share a message for a future visitor. +

+ +
+ +
+ {entries?.map((entry) => ( + + ))} +
+
+ ) +} diff --git a/examples/with-fauna/app/layout.tsx b/examples/with-fauna/app/layout.tsx new file mode 100644 index 000000000000..afbaec717032 --- /dev/null +++ b/examples/with-fauna/app/layout.tsx @@ -0,0 +1,21 @@ +import './globals.css' + +export const metadata: { + title: string + description: string +} = { + title: 'Next.js + Fauna example', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/with-fauna/app/page.tsx b/examples/with-fauna/app/page.tsx new file mode 100644 index 000000000000..8d7292c15598 --- /dev/null +++ b/examples/with-fauna/app/page.tsx @@ -0,0 +1,16 @@ +import { getAllEntries } from '@/lib/fauna' +import GuestbookPage from './guestbook-page' + +export type EntryType = { + id: string + name: string + message: string + createdAt: { + isoString: string + } +} + +export default async function Page() { + const entries = (await getAllEntries()) as EntryType[] + return +} diff --git a/examples/with-fauna/components/EntryForm.tsx b/examples/with-fauna/components/EntryForm.tsx new file mode 100644 index 000000000000..71d867590596 --- /dev/null +++ b/examples/with-fauna/components/EntryForm.tsx @@ -0,0 +1,65 @@ +'use client' + +import cn from 'classnames' +import { createEntryAction } from '@/actions/entry' +// @ts-ignore +import { experimental_useFormState as useFormState } from 'react-dom' +import { experimental_useFormStatus as useFormStatus } from 'react-dom' +import LoadingSpinner from '@/components/LoadingSpinner' +import SuccessMessage from '@/components/SuccessMessage' +import ErrorMessage from '@/components/ErrorMessage' + +const inputClasses = cn( + 'block py-2 bg-white dark:bg-gray-800', + 'rounded-md border-gray-300 focus:ring-blue-500', + 'focus:border-blue-500 text-gray-900 dark:text-gray-100' +) + +const initialState = { + successMessage: null, + errorMessage: null, +} + +export default function EntryForm() { + const [state, formAction] = useFormState(createEntryAction, initialState) + const { pending } = useFormStatus() + + return ( + <> +
+ + + +
+ {state?.successMessage ? ( + {state.successMessage} + ) : null} + {state?.errorMessage ? ( + {state.errorMessage} + ) : null} + + ) +} diff --git a/examples/with-fauna/components/ErrorMessage.js b/examples/with-fauna/components/ErrorMessage.tsx similarity index 85% rename from examples/with-fauna/components/ErrorMessage.js rename to examples/with-fauna/components/ErrorMessage.tsx index 4367acf1e085..e782d4cf2883 100644 --- a/examples/with-fauna/components/ErrorMessage.js +++ b/examples/with-fauna/components/ErrorMessage.tsx @@ -1,4 +1,8 @@ -export default function ErrorMessage({ children }) { +export default function ErrorMessage({ + children, +}: { + children: React.ReactNode +}) { return (

{ - return process.env.FAUNA_DB_DOMAIN ?? 'db.fauna.com' -} - -module.exports = { - resolveDbDomain, -} diff --git a/examples/with-fauna/lib/fauna.js b/examples/with-fauna/lib/fauna.js deleted file mode 100644 index d8e184b09f20..000000000000 --- a/examples/with-fauna/lib/fauna.js +++ /dev/null @@ -1,49 +0,0 @@ -import { GraphQLClient, gql } from 'graphql-request' -import { resolveDbDomain } from './constants' - -const CLIENT_SECRET = - process.env.FAUNA_ADMIN_KEY || process.env.FAUNA_CLIENT_SECRET -const FAUNA_GRAPHQL_DOMAIN = resolveDbDomain().replace('db', 'graphql') -const FAUNA_GRAPHQL_BASE_URL = `https://${FAUNA_GRAPHQL_DOMAIN}/graphql` - -const graphQLClient = new GraphQLClient(FAUNA_GRAPHQL_BASE_URL, { - headers: { - authorization: `Bearer ${CLIENT_SECRET}`, - }, -}) - -export const listGuestbookEntries = () => { - const query = gql` - query Entries($size: Int) { - entries(_size: $size) { - data { - _id - _ts - name - message - createdAt - } - } - } - ` - - return graphQLClient - .request(query, { size: 999 }) - .then(({ entries: { data } }) => data) -} - -export const createGuestbookEntry = (newEntry) => { - const mutation = gql` - mutation CreateGuestbookEntry($input: GuestbookEntryInput!) { - createGuestbookEntry(data: $input) { - _id - _ts - name - message - createdAt - } - } - ` - - return graphQLClient.request(mutation, { input: newEntry }) -} diff --git a/examples/with-fauna/lib/fauna.ts b/examples/with-fauna/lib/fauna.ts new file mode 100644 index 000000000000..09ac49ae7bec --- /dev/null +++ b/examples/with-fauna/lib/fauna.ts @@ -0,0 +1,31 @@ +import 'server-only' +import { Client, fql, QuerySuccess, QueryValueObject } from 'fauna' + +const client = new Client({ + secret: process.env.FAUNA_CLIENT_SECRET, +}) + +export const getAllEntries = async () => { + try { + const dbresponse: QuerySuccess = await client.query(fql` + Entry.all() + `) + return dbresponse.data.data + } catch (error: any) { + throw new Error(error.message) + } +} + +export const createEntry = async (name: string, message: string) => { + try { + const dbresponse = await client.query(fql` + Entry.create({ + name: ${name}, + message: ${message}, + createdAt: Time.now(), + })`) + return dbresponse.data + } catch (error: any) { + throw new Error(error.message) + } +} diff --git a/examples/with-fauna/next.config.js b/examples/with-fauna/next.config.js new file mode 100644 index 000000000000..59e9d7140c9d --- /dev/null +++ b/examples/with-fauna/next.config.js @@ -0,0 +1,7 @@ +const nextConfig = { + experimental: { + serverActions: true, + }, +} + +module.exports = nextConfig diff --git a/examples/with-fauna/package.json b/examples/with-fauna/package.json index 6d44bdf0fbee..8757bb244497 100644 --- a/examples/with-fauna/package.json +++ b/examples/with-fauna/package.json @@ -9,19 +9,17 @@ "dependencies": { "classnames": "2.3.1", "date-fns": "2.28.0", - "faunadb": "4.5.4", - "graphql": "16.8.1", - "graphql-request": "4.3.0", + "fauna": "^1.2.0", "next": "latest", - "react": "18.1.0", - "react-dom": "18.1.0", - "swr": "^2.0.0" + "react": "^18.2.0", + "react-dom": "^18.2.0", + "server-only": "^0.0.1" }, "devDependencies": { - "autoprefixer": "^10.4.7", - "postcss": "^8.4.14", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "request": "^2.88.2", "stream-to-promise": "3.0.0", - "tailwindcss": "^3.1.2", - "request": "^2.88.2" + "tailwindcss": "^3.3.3" } } diff --git a/examples/with-fauna/pages/api/entries/index.js b/examples/with-fauna/pages/api/entries/index.js deleted file mode 100644 index e62f7a3b0c93..000000000000 --- a/examples/with-fauna/pages/api/entries/index.js +++ /dev/null @@ -1,30 +0,0 @@ -import { listGuestbookEntries, createGuestbookEntry } from '@/lib/fauna' - -export default async function handler(req, res) { - const handlers = { - GET: async () => { - const entries = await listGuestbookEntries() - - res.json(entries) - }, - - POST: async () => { - const { - body: { name, message }, - } = req - const created = await createGuestbookEntry({ - name, - message, - createdAt: new Date(), - }) - - res.json(created) - }, - } - - if (!handlers[req.method]) { - return res.status(405).end() - } - - await handlers[req.method]() -} diff --git a/examples/with-fauna/pages/index.js b/examples/with-fauna/pages/index.js deleted file mode 100644 index a14e450fe47e..000000000000 --- a/examples/with-fauna/pages/index.js +++ /dev/null @@ -1,181 +0,0 @@ -import Head from 'next/head' -import { useState } from 'react' -import cn from 'classnames' -import formatDate from 'date-fns/format' -import useSWR, { mutate, SWRConfig } from 'swr' -import 'tailwindcss/tailwind.css' -import { listGuestbookEntries } from '@/lib/fauna' -import SuccessMessage from '@/components/SuccessMessage' -import ErrorMessage from '@/components/ErrorMessage' -import LoadingSpinner from '@/components/LoadingSpinner' - -const fetcher = (url) => fetch(url).then((res) => res.json()) - -const putEntry = (payload) => - fetch('/api/entries', { - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - }, - }).then((res) => (res.ok ? res.json() : Promise.reject(res))) - -const useEntriesFlow = ({ fallback }) => { - const { data: entries } = useSWR('/api/entries', fetcher, { - fallbackData: fallback.entries, - }) - const onSubmit = async (payload) => { - await putEntry(payload) - await mutate('/api/entries') - } - - return { - entries, - onSubmit, - } -} - -const AppHead = () => ( - - - - - -) - -const EntryItem = ({ entry }) => ( -

-
{entry.message}
-
-

{entry.name}

- / -

- {formatDate(new Date(entry.createdAt), "d MMM yyyy 'at' h:mm bb")} -

-
-
-) - -const EntryForm = ({ onSubmit: onSubmitProp }) => { - const initial = { - name: '', - message: '', - } - const [values, setValues] = useState(initial) - const [formState, setFormState] = useState('initial') - const isSubmitting = formState === 'submitting' - - const onSubmit = (ev) => { - ev.preventDefault() - - setFormState('submitting') - onSubmitProp(values) - .then(() => { - setValues(initial) - setFormState('submitted') - }) - .catch(() => { - setFormState('failed') - }) - } - - const makeOnChange = - (fieldName) => - ({ target: { value } }) => - setValues({ - ...values, - [fieldName]: value, - }) - - const inputClasses = cn( - 'block py-2 bg-white dark:bg-gray-800', - 'rounded-md border-gray-300 focus:ring-blue-500', - 'focus:border-blue-500 text-gray-900 dark:text-gray-100' - ) - - return ( - <> -
- - - -
- {{ - failed: () => Something went wrong. :(, - - submitted: () => ( - Thanks for signing the guestbook. - ), - }[formState]?.()} - - ) -} - -const Guestbook = ({ fallback }) => { - const { entries, onSubmit } = useEntriesFlow({ fallback }) - return ( - -
- -
-
- Sign the Guestbook -
-

- Share a message for a future visitor. -

- -
-
- {entries?.map((entry) => ( - - ))} -
-
-
- ) -} - -export async function getStaticProps() { - const entries = await listGuestbookEntries() - return { - props: { - fallback: { - entries, - }, - }, - } -} - -export default Guestbook diff --git a/examples/with-fauna/schema.gql b/examples/with-fauna/schema.gql deleted file mode 100644 index f3a0764e7d88..000000000000 --- a/examples/with-fauna/schema.gql +++ /dev/null @@ -1,14 +0,0 @@ -type GuestbookEntry { - name: String! - message: String! - createdAt: Time! - # _id: Generated by Fauna as each document's unique identifier - # _ts: Timestamp generated by Fauna upon object updating -} - -# A query named 'entries' which returns an array of GuestbookEntry objects -# Implicit arguments: _size (count) and _cursor (location within the Index) -type Query { - entries: [GuestbookEntry!] - @resolver(name: "listLatestEntries", paginated: true) -} diff --git a/examples/with-fauna/scripts/setup.js b/examples/with-fauna/scripts/setup.js deleted file mode 100644 index d83d334a21ce..000000000000 --- a/examples/with-fauna/scripts/setup.js +++ /dev/null @@ -1,193 +0,0 @@ -// This script sets up the database to be used for this example application. -// Look at the code to see what is behind the magic -const fs = require('fs') -const readline = require('readline') -const request = require('request') -const { Client, query: Q } = require('faunadb') -const streamToPromise = require('stream-to-promise') -const { resolveDbDomain } = require('../lib/constants') - -const MakeLatestEntriesIndex = () => - Q.CreateIndex({ - name: 'latestEntries', - source: Q.Collection('GuestbookEntry'), - values: [ - { - field: ['data', 'createdAt'], - reverse: true, - }, - { - field: 'ref', - }, - ], - }) - -const MakeListLatestEntriesUdf = () => - Q.Update(Q.Function('listLatestEntries'), { - // https://docs.fauna.com/fauna/current/api/graphql/functions?lang=javascript#paginated - body: Q.Query( - Q.Lambda( - ['size', 'after', 'before'], - Q.Let( - { - match: Q.Match(Q.Index('latestEntries')), - page: Q.If( - Q.Equals(Q.Var('before'), null), - Q.If( - Q.Equals(Q.Var('after'), null), - Q.Paginate(Q.Var('match'), { - size: Q.Var('size'), - }), - Q.Paginate(Q.Var('match'), { - size: Q.Var('size'), - after: Q.Var('after'), - }) - ), - Q.Paginate(Q.Var('match'), { - size: Q.Var('size'), - before: Q.Var('before'), - }) - ), - }, - Q.Map(Q.Var('page'), Q.Lambda(['_', 'ref'], Q.Get(Q.Var('ref')))) - ) - ) - ), - }) - -const MakeGuestbookRole = () => - Q.CreateRole({ - name: 'GuestbookRole', - privileges: [ - { - resource: Q.Collection('GuestbookEntry'), - actions: { - read: true, - write: true, - create: true, - }, - }, - { - resource: Q.Index('latestEntries'), - actions: { - read: true, - }, - }, - { - resource: Q.Function('listLatestEntries'), - actions: { - call: true, - }, - }, - ], - }) - -const MakeGuestbookKey = () => - Q.CreateKey({ - role: Q.Role('GuestbookRole'), - }) - -const isDatabasePrepared = ({ client }) => - client.query(Q.Exists(Q.Index('latestEntries'))) - -const resolveAdminKey = () => { - if (process.env.FAUNA_ADMIN_KEY) { - return Promise.resolve(process.env.FAUNA_ADMIN_KEY) - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise((resolve, reject) => { - rl.question('Please provide the Fauna admin key:\n', (res) => { - rl.close() - - if (!res) { - return reject( - new Error('You need to provide a key, closing. Try again') - ) - } - - resolve(res) - }) - }) -} - -const importSchema = (adminKey) => { - let domain = resolveDbDomain().replace('db', 'graphql') - return streamToPromise( - fs.createReadStream('./schema.gql').pipe( - request.post({ - model: 'merge', - uri: `https://${domain}/import`, - headers: { - Authorization: `Bearer ${adminKey}`, - }, - }) - ) - ).then(String) -} - -const findImportError = (msg) => { - switch (true) { - case msg.startsWith('Invalid database secret'): - return 'The secret you have provided is not valid, closing. Try again' - case !msg.includes('success'): - return msg - default: - return null - } -} - -const main = async () => { - const adminKey = await resolveAdminKey() - - const client = new Client({ - secret: adminKey, - domain: resolveDbDomain(), - }) - - if (await isDatabasePrepared({ client })) { - return console.info( - 'Fauna resources have already been prepared. ' + - 'If you want to install it once again, please, create a fresh database and re-run the script with the other key' - ) - } - - const importMsg = await importSchema(adminKey) - const importErrorMsg = findImportError(importMsg) - - if (importErrorMsg) { - return Promise.reject(new Error(importErrorMsg)) - } - - console.log('- Successfully imported schema') - - for (const Make of [ - MakeLatestEntriesIndex, - MakeListLatestEntriesUdf, - MakeGuestbookRole, - ]) { - await client.query(Make()) - } - - console.log('- Created Fauna resources') - - if (process.env.FAUNA_ADMIN_KEY) { - // Assume it's a Vercel environment, no need for .env.local file - return - } - - const { secret } = await client.query(MakeGuestbookKey()) - - await fs.promises.writeFile('.env.local', `FAUNA_CLIENT_SECRET=${secret}\n`) - - console.log('- Created .env.local file with secret') -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/examples/with-fauna/tailwind.config.js b/examples/with-fauna/tailwind.config.js index 6aa9dd319121..0b4c17d12670 100644 --- a/examples/with-fauna/tailwind.config.js +++ b/examples/with-fauna/tailwind.config.js @@ -1,8 +1,9 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: [ - './pages/**/*.{js,ts,jsx,tsx}', - './components/**/*.{js,ts,jsx,tsx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: {}, diff --git a/examples/with-fauna/tsconfig.json b/examples/with-fauna/tsconfig.json new file mode 100644 index 000000000000..e06a4454ab06 --- /dev/null +++ b/examples/with-fauna/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/lerna.json b/lerna.json index ad3fab982201..82212d1d74eb 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "13.5.5-canary.4" + "version": "13.5.5-canary.9" } diff --git a/package.json b/package.json index b0c41f67309d..1ffad6d76e11 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "update-google-fonts": "node ./scripts/update-google-fonts.js" }, "devDependencies": { + "@actions/core": "1.10.1", "@babel/core": "7.18.0", "@babel/eslint-parser": "7.18.2", "@babel/generator": "7.18.0", @@ -249,7 +250,7 @@ }, "engines": { "node": ">=16.14.0", - "pnpm": "8.7.1" + "pnpm": "8.9.0" }, - "packageManager": "pnpm@8.7.1" + "packageManager": "pnpm@8.9.0" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 4badb4015965..2e662421fe8f 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index d349a1c12e24..5e917b536b47 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/building-your-application/configuring/eslint#eslint-config", "dependencies": { - "@next/eslint-plugin-next": "13.5.5-canary.4", + "@next/eslint-plugin-next": "13.5.5-canary.9", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", "eslint-import-resolver-node": "^0.3.6", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index b92d5a8596f4..7da03da6fabf 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": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "license": "MIT", diff --git a/packages/font/package.json b/packages/font/package.json index 856f0a79da3e..8e08e7804bae 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,6 +1,6 @@ { "name": "@next/font", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "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 8a557e3557a4..08366e689174 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index f910d7092778..9951d49e949f 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 78eb4c0d87be..651022ad8b73 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 4c754fba02b7..b0bfd040cd3f 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index b89fde2bf621..db4c4a63240e 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "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 5c77a0c4e9bc..148fc7900d41 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "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 3e6adee4c831..4277cc48f6a7 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-swc/crates/next-core/src/next_client/context.rs b/packages/next-swc/crates/next-core/src/next_client/context.rs index 08069da234d6..87679893aaf4 100644 --- a/packages/next-swc/crates/next-core/src/next_client/context.rs +++ b/packages/next-swc/crates/next-core/src/next_client/context.rs @@ -212,9 +212,29 @@ pub async fn get_client_module_options_context( false, next_config, ); - let webpack_rules = - *maybe_add_babel_loader(project_path, *next_config.webpack_rules().await?).await?; - let webpack_rules = maybe_add_sass_loader(next_config.sass_config(), webpack_rules).await?; + + // A separate webpack rules will be applied to codes matching + // foreign_code_context_condition. This allows to import codes from + // node_modules that requires webpack loaders, which next-dev implicitly + // does by default. + let foreign_webpack_rules = maybe_add_sass_loader( + next_config.sass_config(), + *next_config.webpack_rules().await?, + ) + .await?; + let foreign_webpack_loaders = foreign_webpack_rules.map(|rules| { + WebpackLoadersOptions { + rules, + loader_runner_package: Some(get_external_next_compiled_package_mapping(Vc::cell( + "loader-runner".to_owned(), + ))), + } + .cell() + }); + + // Now creates a webpack rules that applies to all codes. + let webpack_rules = *foreign_webpack_rules.clone(); + let webpack_rules = *maybe_add_babel_loader(project_path, webpack_rules).await?; let enable_webpack_loaders = webpack_rules.map(|rules| { WebpackLoadersOptions { rules, @@ -252,9 +272,14 @@ pub async fn get_client_module_options_context( preset_env_versions: Some(env), execution_context: Some(execution_context), custom_ecma_transform_plugins, + ..Default::default() + }; + + let foreign_codes_options_context = ModuleOptionsContext { + enable_webpack_loaders: foreign_webpack_loaders, // NOTE(WEB-1016) PostCSS transforms should also apply to foreign code. enable_postcss_transform: postcss_transform_options.clone(), - ..Default::default() + ..module_options_context.clone() }; let module_options_context = ModuleOptionsContext { @@ -270,7 +295,7 @@ pub async fn get_client_module_options_context( rules: vec![ ( foreign_code_context_condition(next_config, project_path).await?, - module_options_context.clone().cell(), + foreign_codes_options_context.cell(), ), // If the module is an internal asset (i.e overlay, fallback) coming from the embedded // FS, don't apply user defined transforms. diff --git a/packages/next-swc/crates/next-core/src/next_server/context.rs b/packages/next-swc/crates/next-core/src/next_server/context.rs index dd2e3da1b3ac..1b37989f9354 100644 --- a/packages/next-swc/crates/next-core/src/next_server/context.rs +++ b/packages/next-swc/crates/next-core/src/next_server/context.rs @@ -272,9 +272,28 @@ pub async fn get_server_module_options_context( ..Default::default() }); - let webpack_rules = - *maybe_add_babel_loader(project_path, *next_config.webpack_rules().await?).await?; - let webpack_rules = maybe_add_sass_loader(next_config.sass_config(), webpack_rules).await?; + // A separate webpack rules will be applied to codes matching + // foreign_code_context_condition. This allows to import codes from + // node_modules that requires webpack loaders, which next-dev implicitly + // does by default. + let foreign_webpack_rules = maybe_add_sass_loader( + next_config.sass_config(), + *next_config.webpack_rules().await?, + ) + .await?; + let foreign_webpack_loaders = foreign_webpack_rules.map(|rules| { + WebpackLoadersOptions { + rules, + loader_runner_package: Some(get_external_next_compiled_package_mapping(Vc::cell( + "loader-runner".to_owned(), + ))), + } + .cell() + }); + + // Now creates a webpack rules that applies to all codes. + let webpack_rules = *foreign_webpack_rules.clone(); + let webpack_rules = *maybe_add_babel_loader(project_path, webpack_rules).await?; let enable_webpack_loaders = webpack_rules.map(|rules| { WebpackLoadersOptions { rules, @@ -348,6 +367,12 @@ pub async fn get_server_module_options_context( ..Default::default() }; + let foreign_code_module_options_context = ModuleOptionsContext { + custom_rules: internal_custom_rules.clone(), + enable_webpack_loaders: foreign_webpack_loaders, + ..module_options_context.clone() + }; + let internal_module_options_context = ModuleOptionsContext { enable_typescript_transform: Some(TypescriptTransformOptions::default().cell()), enable_jsx: Some(JsxTransformOptions::default().cell()), @@ -365,7 +390,7 @@ pub async fn get_server_module_options_context( rules: vec![ ( foreign_code_context_condition, - module_options_context.clone().cell(), + foreign_code_module_options_context.cell(), ), ( ContextCondition::InPath(next_js_fs().root()), @@ -407,6 +432,11 @@ pub async fn get_server_module_options_context( execution_context: Some(execution_context), ..Default::default() }; + let foreign_code_module_options_context = ModuleOptionsContext { + custom_rules: internal_custom_rules.clone(), + enable_webpack_loaders: foreign_webpack_loaders, + ..module_options_context.clone() + }; let internal_module_options_context = ModuleOptionsContext { enable_typescript_transform: Some(TypescriptTransformOptions::default().cell()), custom_rules: internal_custom_rules, @@ -423,7 +453,7 @@ pub async fn get_server_module_options_context( rules: vec![ ( foreign_code_context_condition, - module_options_context.clone().cell(), + foreign_code_module_options_context.cell(), ), ( ContextCondition::InPath(next_js_fs().root()), @@ -474,6 +504,11 @@ pub async fn get_server_module_options_context( execution_context: Some(execution_context), ..Default::default() }; + let foreign_code_module_options_context = ModuleOptionsContext { + custom_rules: internal_custom_rules.clone(), + enable_webpack_loaders: foreign_webpack_loaders, + ..module_options_context.clone() + }; let internal_module_options_context = ModuleOptionsContext { enable_typescript_transform: Some(TypescriptTransformOptions::default().cell()), custom_rules: internal_custom_rules, @@ -489,7 +524,7 @@ pub async fn get_server_module_options_context( rules: vec![ ( foreign_code_context_condition, - module_options_context.clone().cell(), + foreign_code_module_options_context.cell(), ), ( ContextCondition::InPath(next_js_fs().root()), diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 08e919121097..c611957d3263 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "private": true, "scripts": { "clean": "node ../../scripts/rm.mjs native", diff --git a/packages/next/package.json b/packages/next/package.json index c5e0808ee5a2..a8e4b894dba1 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "13.5.5-canary.4", + "version": "13.5.5-canary.9", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -90,7 +90,7 @@ ] }, "dependencies": { - "@next/env": "13.5.5-canary.4", + "@next/env": "13.5.5-canary.9", "@swc/helpers": "0.5.2", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001406", @@ -144,11 +144,11 @@ "@mswjs/interceptors": "0.23.0", "@napi-rs/cli": "2.16.2", "@napi-rs/triples": "1.1.0", - "@next/polyfill-module": "13.5.5-canary.4", - "@next/polyfill-nomodule": "13.5.5-canary.4", - "@next/react-dev-overlay": "13.5.5-canary.4", - "@next/react-refresh-utils": "13.5.5-canary.4", - "@next/swc": "13.5.5-canary.4", + "@next/polyfill-module": "13.5.5-canary.9", + "@next/polyfill-nomodule": "13.5.5-canary.9", + "@next/react-dev-overlay": "13.5.5-canary.9", + "@next/react-refresh-utils": "13.5.5-canary.9", + "@next/swc": "13.5.5-canary.9", "@opentelemetry/api": "1.4.1", "@playwright/test": "^1.35.1", "@taskr/clear": "1.1.0", diff --git a/packages/next/src/build/collect-build-traces.ts b/packages/next/src/build/collect-build-traces.ts index 54f72c254a91..c0b60922cb97 100644 --- a/packages/next/src/build/collect-build-traces.ts +++ b/packages/next/src/build/collect-build-traces.ts @@ -233,7 +233,7 @@ export async function collectBuildTraces({ }) } } - const ignores = [ + const serverIgnores = [ '**/*.d.ts', '**/*.map', isStandalone ? null : '**/next/dist/compiled/jest-worker/**/*', @@ -241,6 +241,8 @@ export async function collectBuildTraces({ '**/node_modules/webpack5/**/*', '**/next/dist/server/lib/squoosh/**/*.wasm', '**/next/dist/server/lib/route-resolver*', + '**/next/dist/pages/**/*', + ...(ciEnvironment.hasNextSupport ? [ // only ignore image-optimizer code when @@ -261,12 +263,12 @@ export async function collectBuildTraces({ ...(config.experimental.outputFileTracingIgnores || []), ].filter(nonNullable) - const ignoreFn = (pathname: string) => { + const serverIgnoreFn = (pathname: string) => { if (path.isAbsolute(pathname) && !pathname.startsWith(root)) { return true } - return isMatch(pathname, ignores, { + return isMatch(pathname, serverIgnores, { contains: true, dot: true, }) @@ -321,7 +323,7 @@ export async function collectBuildTraces({ [minimalServerTracedFiles, minimalFiles], ] as [Set, string[]][]) { for (const file of files) { - if (!ignoreFn(path.join(traceContext, file))) { + if (!serverIgnoreFn(path.join(traceContext, file))) { addToTracedFiles(traceContext, file, set) } } @@ -336,7 +338,6 @@ export async function collectBuildTraces({ const result = await nodeFileTrace(chunksToTrace, { base: outputFileTracingRoot, processCwd: dir, - ignore: ignoreFn, mixedModules: true, }) const reasons = result.reasons @@ -360,12 +361,7 @@ export async function collectBuildTraces({ for (const curFile of curFiles || []) { const filePath = path.join(outputFileTracingRoot, curFile) - if ( - !isMatch(filePath, '**/next/dist/pages/**/*', { - dot: true, - contains: true, - }) - ) { + if (!serverIgnoreFn(filePath)) { tracedFiles.add( path.relative(distDir, filePath).replace(/\\/g, '/') ) diff --git a/packages/next/src/build/handle-externals.ts b/packages/next/src/build/handle-externals.ts index 2e0eb816b149..853ab2e3c68a 100644 --- a/packages/next/src/build/handle-externals.ts +++ b/packages/next/src/build/handle-externals.ts @@ -44,7 +44,6 @@ export async function resolveExternal( context: string, request: string, isEsmRequested: boolean, - hasAppDir: boolean, getResolve: ( options: any ) => ( @@ -66,11 +65,7 @@ export async function resolveExternal( let preferEsmOptions = esmExternals && isEsmRequested ? [true, false] : [false] - // Disable esm resolving for app/ and pages/ so for esm package using under pages/ - // won't load react through esm loader - if (hasAppDir) { - preferEsmOptions = [false] - } + for (const preferEsm of preferEsmOptions) { const resolve = getResolve( preferEsm ? esmResolveOptions : nodeResolveOptions @@ -135,12 +130,10 @@ export function makeExternalHandler({ config, optOutBundlingPackageRegex, dir, - hasAppDir, }: { config: NextConfigComplete optOutBundlingPackageRegex: RegExp dir: string - hasAppDir: boolean }) { let resolvedExternalPackageDirs: Map const looseEsmExternals = config.experimental?.esmExternals === 'loose' @@ -293,7 +286,6 @@ export function makeExternalHandler({ context, request, isEsmRequested, - hasAppDir, getResolve, isLocal ? resolveNextExternal : undefined ) @@ -353,7 +345,6 @@ export function makeExternalHandler({ config.experimental.esmExternals, context, pkg + '/package.json', - hasAppDir, isEsmRequested, getResolve, isLocal ? resolveNextExternal : undefined diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index a5bc2ea34f2e..580095c00d1a 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -941,7 +941,7 @@ export default async function build( ? path.relative(distDir, incrementalCacheHandlerPath) : undefined, - isExperimentalCompile: true, + isExperimentalCompile: isCompile, }, }, appDir: dir, diff --git a/packages/next/src/build/webpack-build/index.ts b/packages/next/src/build/webpack-build/index.ts index 6d55342f1ebc..1fe5f3f50f0c 100644 --- a/packages/next/src/build/webpack-build/index.ts +++ b/packages/next/src/build/webpack-build/index.ts @@ -24,7 +24,7 @@ function deepMerge(target: any, source: any) { ? (target[key] = [...target[key], ...(source[key] || [])]) : typeof target[key] == 'object' && typeof source[key] == 'object' ? deepMerge(target[key], source[key]) - : structuredClone(result[key]) + : result[key] } return result } diff --git a/packages/next/src/build/webpack-config-rules/resolve.ts b/packages/next/src/build/webpack-config-rules/resolve.ts new file mode 100644 index 000000000000..f50f6c92ee62 --- /dev/null +++ b/packages/next/src/build/webpack-config-rules/resolve.ts @@ -0,0 +1,40 @@ +import { + COMPILER_NAMES, + type CompilerNameValues, +} from '../../shared/lib/constants' + +// exports. +export const edgeConditionNames = [ + 'edge-light', + 'worker', + // inherits the default conditions + '...', +] + +const mainFieldsPerCompiler: Record< + CompilerNameValues | 'app-router-server', + string[] +> = { + // For default case, prefer CJS over ESM on server side. e.g. pages dir SSR + [COMPILER_NAMES.server]: ['main', 'module'], + [COMPILER_NAMES.client]: ['browser', 'module', 'main'], + [COMPILER_NAMES.edgeServer]: edgeConditionNames, + // For app router since everything is bundled, prefer ESM over CJS + 'app-router-server': ['module', 'main'], +} + +export function getMainField( + pageType: 'app' | 'pages', + compilerType: CompilerNameValues +) { + if (compilerType === COMPILER_NAMES.edgeServer) { + return edgeConditionNames + } else if (compilerType === COMPILER_NAMES.client) { + return mainFieldsPerCompiler[COMPILER_NAMES.client] + } + + // Prefer module fields over main fields for isomorphic packages on server layer + return pageType === 'app' + ? mainFieldsPerCompiler['app-router-server'] + : mainFieldsPerCompiler[COMPILER_NAMES.server] +} diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 1a3344be3a1d..a20cf97a1e97 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -74,6 +74,10 @@ import { needsExperimentalReact } from '../lib/needs-experimental-react' import { getDefineEnvPlugin } from './webpack/plugins/define-env-plugin' import type { SWCLoaderOptions } from './webpack/loaders/next-swc-loader' import { isResourceInPackages, makeExternalHandler } from './handle-externals' +import { + getMainField, + edgeConditionNames, +} from './webpack-config-rules/resolve' type ExcludesFalse = (x: T | false) => x is T type ClientEntries = { @@ -104,21 +108,6 @@ const babelIncludeRegexes: RegExp[] = [ const asyncStoragesRegex = /next[\\/]dist[\\/](esm[\\/])?client[\\/]components[\\/](static-generation-async-storage|action-async-storage|request-async-storage)/ -// exports. -const edgeConditionNames = [ - 'edge-light', - 'worker', - // inherits the default conditions - '...', -] - -// packageJson. -const mainFieldsPerCompiler: Record = { - [COMPILER_NAMES.server]: ['main', 'module'], - [COMPILER_NAMES.client]: ['browser', 'module', 'main'], - [COMPILER_NAMES.edgeServer]: edgeConditionNames, -} - // Support for NODE_PATH const nodePathList = (process.env.NODE_PATH || '') .split(process.platform === 'win32' ? ';' : ':') @@ -822,28 +811,41 @@ export default async function getBaseWebpackConfig( 'next/dist/server': 'next/dist/esm/server', // Alias the usage of next public APIs - [`${NEXT_PROJECT_ROOT}/server`]: + [path.join(NEXT_PROJECT_ROOT, 'server')]: 'next/dist/esm/server/web/exports/index', - [`${NEXT_PROJECT_ROOT}/dist/client/link`]: + [path.join(NEXT_PROJECT_ROOT_DIST, 'client', 'link')]: 'next/dist/esm/client/link', - [`${NEXT_PROJECT_ROOT}/dist/shared/lib/image-external`]: - 'next/dist/esm/shared/lib/image-external', - [`${NEXT_PROJECT_ROOT}/dist/client/script`]: + [path.join( + NEXT_PROJECT_ROOT, + 'dist', + 'shared', + 'lib', + 'image-external' + )]: 'next/dist/esm/shared/lib/image-external', + [path.join(NEXT_PROJECT_ROOT_DIST, 'client', 'script')]: 'next/dist/esm/client/script', - [`${NEXT_PROJECT_ROOT}/dist/client/router`]: + [path.join(NEXT_PROJECT_ROOT_DIST, 'client', 'router')]: 'next/dist/esm/client/router', - [`${NEXT_PROJECT_ROOT}/dist/shared/lib/head`]: + [path.join(NEXT_PROJECT_ROOT_DIST, 'shared', 'lib', 'head')]: 'next/dist/esm/shared/lib/head', - [`${NEXT_PROJECT_ROOT}/dist/shared/lib/dynamic`]: + [path.join(NEXT_PROJECT_ROOT_DIST, 'shared', 'lib', 'dynamic')]: 'next/dist/esm/shared/lib/dynamic', - [`${NEXT_PROJECT_ROOT}/dist/pages/_document`]: + [path.join(NEXT_PROJECT_ROOT_DIST, 'pages', '_document')]: 'next/dist/esm/pages/_document', - [`${NEXT_PROJECT_ROOT}/dist/pages/_app`]: + [path.join(NEXT_PROJECT_ROOT_DIST, 'pages', '_app')]: 'next/dist/esm/pages/_app', - [`${NEXT_PROJECT_ROOT}/dist/client/components/navigation`]: - 'next/dist/esm/client/components/navigation', - [`${NEXT_PROJECT_ROOT}/dist/client/components/headers`]: - 'next/dist/esm/client/components/headers', + [path.join( + NEXT_PROJECT_ROOT_DIST, + 'client', + 'components', + 'navigation' + )]: 'next/dist/esm/client/components/navigation', + [path.join( + NEXT_PROJECT_ROOT_DIST, + 'client', + 'components', + 'headers' + )]: 'next/dist/esm/client/components/headers', } : undefined), @@ -918,7 +920,8 @@ export default async function getBaseWebpackConfig( }, } : undefined), - mainFields: mainFieldsPerCompiler[compilerType], + // default main fields use pages dir ones, and customize app router ones in loaders. + mainFields: getMainField('pages', compilerType), ...(isEdgeServer && { conditionNames: edgeConditionNames, }), @@ -1026,7 +1029,6 @@ export default async function getBaseWebpackConfig( config, optOutBundlingPackageRegex, dir, - hasAppDir, }) const shouldIncludeExternalDirs = @@ -1597,6 +1599,7 @@ export default async function getBaseWebpackConfig( ], }, resolve: { + mainFields: getMainField('app', compilerType), conditionNames: reactServerCondition, // If missing the alias override here, the default alias will be used which aliases // react to the direct file path, not the package name. In that case the condition @@ -1741,6 +1744,9 @@ export default async function getBaseWebpackConfig( ], exclude: [codeCondition.exclude], use: swcLoaderForClientLayer, + resolve: { + mainFields: getMainField('app', compilerType), + }, }, ] : []), diff --git a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts index dea29c64be74..b7cd2e09a257 100644 --- a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts +++ b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts @@ -16,15 +16,7 @@ import { SERVER_RUNTIME } from '../../../../lib/constants' import type { PrerenderManifest } from '../../..' import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' import type { SizeLimit } from '../../../../../types' - -const NEXT_PRIVATE_GLOBAL_WAIT_UNTIL = Symbol.for( - '__next_private_global_wait_until__' -) - -// @ts-ignore -globalThis[NEXT_PRIVATE_GLOBAL_WAIT_UNTIL] = - // @ts-ignore - globalThis[NEXT_PRIVATE_GLOBAL_WAIT_UNTIL] || [] +import { internal_getCurrentFunctionWaitUntil } from '../../../../server/web/internal-edge-wait-until' export function getRender({ dev, @@ -161,10 +153,10 @@ export function getRender({ const result = await extendedRes.toResponse() if (event && event.waitUntil) { - event.waitUntil( - // @ts-ignore - Promise.all([...globalThis[NEXT_PRIVATE_GLOBAL_WAIT_UNTIL]]) - ) + const waitUntilPromise = internal_getCurrentFunctionWaitUntil() + if (waitUntilPromise) { + event.waitUntil(waitUntilPromise) + } } // fetchMetrics is attached to the web request that going through the server, diff --git a/packages/next/src/build/webpack/plugins/build-manifest-plugin.ts b/packages/next/src/build/webpack/plugins/build-manifest-plugin.ts index 015dd08426bf..9b9c3006d9a5 100644 --- a/packages/next/src/build/webpack/plugins/build-manifest-plugin.ts +++ b/packages/next/src/build/webpack/plugins/build-manifest-plugin.ts @@ -20,13 +20,37 @@ import { spans } from './profiling-plugin' type DeepMutable = { -readonly [P in keyof T]: DeepMutable } -export type ClientBuildManifest = Record +export type ClientBuildManifest = { + [key: string]: string[] +} // Add the runtime ssg manifest file as a lazy-loaded file dependency. // We also stub this file out for development mode (when it is not // generated). export const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` +function normalizeRewrite(item: { + source: string + destination: string + has?: any +}): CustomRoutes['rewrites']['beforeFiles'][0] { + return { + has: item.has, + source: item.source, + destination: item.destination, + } +} + +export function normalizeRewritesForBuildManifest( + rewrites: CustomRoutes['rewrites'] +): CustomRoutes['rewrites'] { + return { + afterFiles: rewrites.afterFiles?.map((item) => normalizeRewrite(item)), + beforeFiles: rewrites.beforeFiles?.map((item) => normalizeRewrite(item)), + fallback: rewrites.fallback?.map((item) => normalizeRewrite(item)), + } +} + // This function takes the asset map generated in BuildManifestPlugin and creates a // reduced version to send to the client. function generateClientManifest( @@ -40,27 +64,9 @@ function generateClientManifest( 'NextJsBuildManifest-generateClientManifest' ) - const normalizeRewrite = (item: { - source: string - destination: string - has?: any - }) => { - return { - has: item.has, - source: item.source, - destination: item.destination, - } - } - return genClientManifestSpan?.traceFn(() => { const clientManifest: ClientBuildManifest = { - __rewrites: { - afterFiles: rewrites.afterFiles?.map((item) => normalizeRewrite(item)), - beforeFiles: rewrites.beforeFiles?.map((item) => - normalizeRewrite(item) - ), - fallback: rewrites.fallback?.map((item) => normalizeRewrite(item)), - } as any, + __rewrites: normalizeRewritesForBuildManifest(rewrites) as any, } const appDependencies = new Set(assetMap.pages['/_app']) const sortedPageKeys = getSortedRoutes(Object.keys(assetMap.pages)) diff --git a/packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts b/packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts index 1f61d3520d29..5ea193152a28 100644 --- a/packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts +++ b/packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts @@ -743,7 +743,6 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance { context, request, isEsmRequested, - !!this.appDirEnabled, (options) => (_: string, resRequest: string) => { return getResolve(options)(parent, resRequest, job) }, diff --git a/packages/next/src/client/dev/error-overlay/websocket.ts b/packages/next/src/client/dev/error-overlay/websocket.ts index 5a90b045d186..b59cc8052d0e 100644 --- a/packages/next/src/client/dev/error-overlay/websocket.ts +++ b/packages/next/src/client/dev/error-overlay/websocket.ts @@ -26,11 +26,14 @@ export function sendMessage(data: string) { return source.send(data) } +let reconnections = 0 + export function connectHMR(options: { path: string; assetPrefix: string }) { function init() { if (source) source.close() function handleOnline() { + reconnections = 0 window.console.log('[HMR] connected') } @@ -42,11 +45,21 @@ export function connectHMR(options: { path: string; assetPrefix: string }) { } } + let timer: ReturnType function handleDisconnect() { source.onerror = null source.onclose = null source.close() - init() + reconnections++ + // After 25 reconnects we'll want to reload the page as it indicates the dev server is no longer running. + if (reconnections > 25) { + window.location.reload() + return + } + + clearTimeout(timer) + // Try again after 5 seconds + timer = setTimeout(init, reconnections > 5 ? 5000 : 1000) } const { hostname, port } = location diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 1f2f7ab7efde..740957c99ee1 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -33,14 +33,14 @@ export function createMetadataComponents({ pathname, searchParams, getDynamicParamFromSegment, - appUsingSizeAdjust, + appUsingSizeAdjustment, errorType, }: { tree: LoaderTree pathname: string searchParams: { [key: string]: any } getDynamicParamFromSegment: GetDynamicParamFromSegment - appUsingSizeAdjust: boolean + appUsingSizeAdjustment: boolean errorType?: 'not-found' | 'redirect' }): [React.ComponentType, React.ComponentType] { const metadataContext = { @@ -110,7 +110,7 @@ export function createMetadataComponents({ IconsMetadata({ icons: metadata.icons }), ]) - if (appUsingSizeAdjust) elements.push() + if (appUsingSizeAdjustment) elements.push() return ( <> diff --git a/packages/next/src/lib/worker.ts b/packages/next/src/lib/worker.ts index fed8da1b8b66..5cc0af1a18aa 100644 --- a/packages/next/src/lib/worker.ts +++ b/packages/next/src/lib/worker.ts @@ -133,9 +133,7 @@ export class Worker { } const wrapMethodWithTimeout = - Promise | unknown>( - method: M - ) => + (methodName: keyof T) => async (...args: Args) => { activeTasks++ @@ -149,7 +147,8 @@ export class Worker { const result = await Promise.race([ // Either we'll get the result from the worker, or we'll get the // restart promise to fire. - method(...args), + // @ts-expect-error - we're grabbing a dynamic method on the worker + this._worker[methodName](...args), restartPromise, ]) @@ -160,7 +159,7 @@ export class Worker { } // Otherwise, we'll need to restart the worker, and try again. - if (onRestart) onRestart(method.name, args, ++attempts) + if (onRestart) onRestart(methodName.toString(), args, ++attempts) } } finally { activeTasks-- @@ -174,7 +173,7 @@ export class Worker { // @ts-expect-error - we're grabbing a dynamic method on the worker let method = this._worker[name].bind(this._worker) if (timeout) { - method = wrapMethodWithTimeout(method) + method = wrapMethodWithTimeout(name) } // @ts-expect-error - we're dynamically creating methods diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index aaa5f5af8b7d..05d1a88e9b74 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -21,7 +21,6 @@ import { import RenderResult from '../render-result' import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' import { FlightRenderResult } from './flight-render-result' -import type { ActionResult } from './types' import type { ActionAsyncStorage } from '../../client/components/action-async-storage.external' import { filterReqHeaders, @@ -37,6 +36,7 @@ import { NEXT_CACHE_REVALIDATED_TAGS_HEADER, NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, } from '../../lib/constants' +import type { AppRenderContext, GenerateFlight } from './app-render' function nodeToWebReadableStream(nodeReadable: import('stream').Readable) { if (process.env.NEXT_RUNTIME !== 'edge') { @@ -246,21 +246,18 @@ export async function handleAction({ staticGenerationStore, requestStore, serverActionsBodySizeLimit, + ctx, }: { req: IncomingMessage res: ServerResponse ComponentMod: any page: string serverActionsManifest: any - generateFlight: (options: { - actionResult: ActionResult - formState?: any - skipFlight: boolean - asNotFound?: boolean - }) => Promise + generateFlight: GenerateFlight staticGenerationStore: StaticGenerationStore requestStore: RequestStore serverActionsBodySizeLimit?: SizeLimit + ctx: AppRenderContext }): Promise< | undefined | { @@ -284,269 +281,328 @@ export async function handleAction({ typeof actionId === 'string' && req.method === 'POST' - if (isFetchAction || isURLEncodedAction || isMultipartAction) { - // ensure we avoid caching server actions unexpectedly - res.setHeader( - 'Cache-Control', - 'no-cache, no-store, max-age=0, must-revalidate' + // If it's not a Server Action, skip handling. + if (!(isFetchAction || isURLEncodedAction || isMultipartAction)) { + return + } + + const originHostname = + typeof req.headers['origin'] === 'string' + ? new URL(req.headers['origin']).host + : undefined + const host = req.headers['x-forwarded-host'] || req.headers['host'] + + // This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to + // ensure that the request is coming from the same host. + if (!originHostname) { + // This might be an old browser that doesn't send `host` header. We ignore + // this case. + console.warn( + 'Missing `origin` header from a forwarded Server Actions request.' ) - let bound = [] - - const workerName = 'app' + page - const serverModuleMap = new Proxy( - {}, - { - get: (_, id: string) => { - return { - id: serverActionsManifest[ - process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node' - ][id].workers[workerName], - name: id, - chunks: [], - } - }, - } + } else if (!host || originHostname !== host) { + // This is an attack. We should not proceed the action. + console.error( + '`x-forwarded-host` and `host` headers do not match `origin` header from a forwarded Server Actions request. Aborting the action.' ) - const { actionAsyncStorage } = ComponentMod as { - actionAsyncStorage: ActionAsyncStorage + const error = new Error('Invalid Server Actions request.') + + if (isFetchAction) { + res.statusCode = 500 + await Promise.all(staticGenerationStore.pendingRevalidates || []) + const promise = Promise.reject(error) + try { + await promise + } catch {} + + return { + type: 'done', + result: await generateFlight(ctx, { + actionResult: promise, + // if the page was not revalidated, we can skip the rendering the flight tree + skipFlight: !staticGenerationStore.pathWasRevalidated, + }), + } } - let actionResult: RenderResult | undefined - let formState: any | undefined + throw error + } - try { - await actionAsyncStorage.run({ isAction: true }, async () => { - if (process.env.NEXT_RUNTIME === 'edge') { - // Use react-server-dom-webpack/server.edge - const { decodeReply, decodeAction, decodeFormState } = ComponentMod - - const webRequest = req as unknown as WebNextRequest - if (!webRequest.body) { - throw new Error('invariant: Missing request body.') - } + // ensure we avoid caching server actions unexpectedly + res.setHeader( + 'Cache-Control', + 'no-cache, no-store, max-age=0, must-revalidate' + ) + let bound = [] - if (isMultipartAction) { - // TODO-APP: Add streaming support - const formData = await webRequest.request.formData() - if (isFetchAction) { - bound = await decodeReply(formData, serverModuleMap) - } else { - const action = await decodeAction(formData, serverModuleMap) - const actionReturnedState = await action() - formState = decodeFormState(actionReturnedState, formData) - - // Skip the fetch path - return - } - } else { - let actionData = '' + const workerName = 'app' + page + const serverModuleMap = new Proxy( + {}, + { + get: (_, id: string) => { + return { + id: serverActionsManifest[ + process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node' + ][id].workers[workerName], + name: id, + chunks: [], + } + }, + } + ) - const reader = webRequest.body.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } + const { actionAsyncStorage } = ComponentMod as { + actionAsyncStorage: ActionAsyncStorage + } - actionData += new TextDecoder().decode(value) - } + let actionResult: RenderResult | undefined + let formState: any | undefined - if (isURLEncodedAction) { - const formData = formDataFromSearchQueryString(actionData) - bound = await decodeReply(formData, serverModuleMap) - } else { - bound = await decodeReply(actionData, serverModuleMap) - } + try { + await actionAsyncStorage.run({ isAction: true }, async () => { + if (process.env.NEXT_RUNTIME === 'edge') { + // Use react-server-dom-webpack/server.edge + const { decodeReply, decodeAction, decodeFormState } = ComponentMod + + const webRequest = req as unknown as WebNextRequest + if (!webRequest.body) { + throw new Error('invariant: Missing request body.') + } + + if (isMultipartAction) { + // TODO-APP: Add streaming support + const formData = await webRequest.request.formData() + if (isFetchAction) { + bound = await decodeReply(formData, serverModuleMap) + } else { + const action = await decodeAction(formData, serverModuleMap) + const actionReturnedState = await action() + formState = decodeFormState(actionReturnedState, formData) + + // Skip the fetch path + return } } else { - // Use react-server-dom-webpack/server.node which supports streaming - const { - decodeReply, - decodeReplyFromBusboy, - decodeAction, - decodeFormState, - } = require(`./react-server.node`) - - if (isMultipartAction) { - if (isFetchAction) { - const busboy = require('busboy') - const bb = busboy({ headers: req.headers }) - req.pipe(bb) - - bound = await decodeReplyFromBusboy(bb, serverModuleMap) - } else { - // React doesn't yet publish a busboy version of decodeAction - // so we polyfill the parsing of FormData. - const UndiciRequest = require('next/dist/compiled/undici').Request - const fakeRequest = new UndiciRequest('http://localhost', { - method: 'POST', - headers: { 'Content-Type': req.headers['content-type'] }, - body: nodeToWebReadableStream(req), - duplex: 'half', - }) - const formData = await fakeRequest.formData() - const action = await decodeAction(formData, serverModuleMap) - const actionReturnedState = await action() - formState = await decodeFormState(actionReturnedState, formData) - - // Skip the fetch path - return - } - } else { - const { parseBody } = - require('../api-utils/node/parse-body') as typeof import('../api-utils/node/parse-body') - - let actionData - try { - actionData = - (await parseBody(req, serverActionsBodySizeLimit ?? '1mb')) || - '' - } catch (e: any) { - if (e && (e as ApiError).statusCode === 413) { - // Exceeded the size limit - e.message = - e.message + - '\nTo configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/server-actions#size-limitation' - } - throw e - } + let actionData = '' - if (isURLEncodedAction) { - const formData = formDataFromSearchQueryString(actionData) - bound = await decodeReply(formData, serverModuleMap) - } else { - bound = await decodeReply(actionData, serverModuleMap) + const reader = webRequest.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break } - } - } - // actions.js - // app/page.js - // action worker1 - // appRender1 + actionData += new TextDecoder().decode(value) + } - // app/foo/page.js - // action worker2 - // appRender + if (isURLEncodedAction) { + const formData = formDataFromSearchQueryString(actionData) + bound = await decodeReply(formData, serverModuleMap) + } else { + bound = await decodeReply(actionData, serverModuleMap) + } + } + } else { + // Use react-server-dom-webpack/server.node which supports streaming + const { + decodeReply, + decodeReplyFromBusboy, + decodeAction, + decodeFormState, + } = require(`./react-server.node`) + + if (isMultipartAction) { + if (isFetchAction) { + const busboy = require('busboy') + const bb = busboy({ headers: req.headers }) + req.pipe(bb) + + bound = await decodeReplyFromBusboy(bb, serverModuleMap) + } else { + // React doesn't yet publish a busboy version of decodeAction + // so we polyfill the parsing of FormData. + const UndiciRequest = require('next/dist/compiled/undici').Request + const fakeRequest = new UndiciRequest('http://localhost', { + method: 'POST', + headers: { 'Content-Type': req.headers['content-type'] }, + body: nodeToWebReadableStream(req), + duplex: 'half', + }) + const formData = await fakeRequest.formData() + const action = await decodeAction(formData, serverModuleMap) + const actionReturnedState = await action() + formState = await decodeFormState(actionReturnedState, formData) + + // Skip the fetch path + return + } + } else { + const { parseBody } = + require('../api-utils/node/parse-body') as typeof import('../api-utils/node/parse-body') - // / -> fire action -> POST / -> appRender1 -> modId for the action file - // /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file + let actionData + try { + actionData = + (await parseBody(req, serverActionsBodySizeLimit ?? '1mb')) || '' + } catch (e: any) { + if (e && (e as ApiError).statusCode === 413) { + // Exceeded the size limit + e.message = + e.message + + '\nTo configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/server-actions#size-limitation' + } + throw e + } - const actionModId = - serverActionsManifest[ - process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node' - ][actionId].workers[workerName] - const actionHandler = - ComponentMod.__next_app__.require(actionModId)[actionId] - - const returnVal = await actionHandler.apply(null, bound) - - // For form actions, we need to continue rendering the page. - if (isFetchAction) { - await addRevalidationHeader(res, { - staticGenerationStore, - requestStore, - }) - - actionResult = await generateFlight({ - actionResult: Promise.resolve(returnVal), - // if the page was not revalidated, we can skip the rendering the flight tree - skipFlight: !staticGenerationStore.pathWasRevalidated, - }) + if (isURLEncodedAction) { + const formData = formDataFromSearchQueryString(actionData) + bound = await decodeReply(formData, serverModuleMap) + } else { + bound = await decodeReply(actionData, serverModuleMap) + } } - }) + } - return { - type: 'done', - result: actionResult, - formState, + // actions.js + // app/page.js + // action worker1 + // appRender1 + + // app/foo/page.js + // action worker2 + // appRender + + // / -> fire action -> POST / -> appRender1 -> modId for the action file + // /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file + + // Get all workers that include this action + const actionWorkers = + serverActionsManifest[ + process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node' + ][actionId] + + if (!actionWorkers) { + // When this happens, it could be a deployment skew where the action came + // from a different deployment. We'll just return a 404 with a message logged. + console.error( + `Failed to find Server Action "${actionId}". This request might be from an older or newer deployment.` + ) + return { + type: 'not-found', + } } - } catch (err) { - if (isRedirectError(err)) { - const redirectUrl = getURLFromRedirectError(err) - // if it's a fetch action, we don't want to mess with the status code - // and we'll handle it on the client router + const actionModId = actionWorkers.workers[workerName] + const actionHandler = + ComponentMod.__next_app__.require(actionModId)[actionId] + + const returnVal = await actionHandler.apply(null, bound) + + // For form actions, we need to continue rendering the page. + if (isFetchAction) { await addRevalidationHeader(res, { staticGenerationStore, requestStore, }) - if (isFetchAction) { - return { - type: 'done', - result: await createRedirectRenderResult( - req, - res, - redirectUrl, - staticGenerationStore - ), - } - } - - if (err.mutableCookies) { - const headers = new Headers() + actionResult = await generateFlight(ctx, { + actionResult: Promise.resolve(returnVal), + // if the page was not revalidated, we can skip the rendering the flight tree + skipFlight: !staticGenerationStore.pathWasRevalidated, + }) + } + }) - // If there were mutable cookies set, we need to set them on the - // response. - if (appendMutableCookies(headers, err.mutableCookies)) { - res.setHeader('set-cookie', Array.from(headers.values())) - } - } + return { + type: 'done', + result: actionResult, + formState, + } + } catch (err) { + if (isRedirectError(err)) { + const redirectUrl = getURLFromRedirectError(err) + + // if it's a fetch action, we don't want to mess with the status code + // and we'll handle it on the client router + await addRevalidationHeader(res, { + staticGenerationStore, + requestStore, + }) - res.setHeader('Location', redirectUrl) - res.statusCode = 303 + if (isFetchAction) { return { type: 'done', - result: new RenderResult(''), + result: await createRedirectRenderResult( + req, + res, + redirectUrl, + staticGenerationStore + ), } - } else if (isNotFoundError(err)) { - res.statusCode = 404 + } - await addRevalidationHeader(res, { - staticGenerationStore, - requestStore, - }) + if (err.mutableCookies) { + const headers = new Headers() - if (isFetchAction) { - const promise = Promise.reject(err) - try { - await promise - } catch {} - return { - type: 'done', - result: await generateFlight({ - skipFlight: false, - actionResult: promise, - asNotFound: true, - }), - } - } - return { - type: 'not-found', + // If there were mutable cookies set, we need to set them on the + // response. + if (appendMutableCookies(headers, err.mutableCookies)) { + res.setHeader('set-cookie', Array.from(headers.values())) } } + res.setHeader('Location', redirectUrl) + res.statusCode = 303 + return { + type: 'done', + result: new RenderResult(''), + } + } else if (isNotFoundError(err)) { + res.statusCode = 404 + + await addRevalidationHeader(res, { + staticGenerationStore, + requestStore, + }) + if (isFetchAction) { - res.statusCode = 500 - await Promise.all(staticGenerationStore.pendingRevalidates || []) const promise = Promise.reject(err) try { await promise } catch {} - return { type: 'done', - result: await generateFlight({ + result: await generateFlight(ctx, { + skipFlight: false, actionResult: promise, - // if the page was not revalidated, we can skip the rendering the flight tree - skipFlight: !staticGenerationStore.pathWasRevalidated, + asNotFound: true, }), } } + return { + type: 'not-found', + } + } + + if (isFetchAction) { + res.statusCode = 500 + await Promise.all(staticGenerationStore.pendingRevalidates || []) + const promise = Promise.reject(err) + try { + await promise + } catch {} - throw err + return { + type: 'done', + result: await generateFlight(ctx, { + actionResult: promise, + // if the page was not revalidated, we can skip the rendering the flight tree + skipFlight: !staticGenerationStore.pathWasRevalidated, + }), + } } + + throw err } } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 649264ffccb1..909017d6b669 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1,10 +1,8 @@ import type { IncomingMessage, ServerResponse } from 'http' import type { ActionResult, - ChildProp, DynamicParamTypesShort, FlightData, - FlightDataPath, FlightRouterState, FlightSegmentPath, RenderOpts, @@ -25,10 +23,7 @@ import { streamToBufferedResult, cloneTransformStream, } from '../stream-utils/node-web-streams-helper' -import { - canSegmentBeOverridden, - matchSegment, -} from '../../client/components/match-segments' +import { canSegmentBeOverridden } from '../../client/components/match-segments' import { stripInternalQueries } from '../internal-utils' import { NEXT_ROUTER_PREFETCH, @@ -38,8 +33,6 @@ import { import { createMetadataComponents } from '../../lib/metadata/metadata' import { RequestAsyncStorageWrapper } from '../async-storage/request-async-storage-wrapper' import { StaticGenerationAsyncStorageWrapper } from '../async-storage/static-generation-async-storage-wrapper' -import { isClientReference } from '../../lib/client-reference' -import { getLayoutOrPageModule } from '../lib/app-dir-module' import type { LoaderTree } from '../lib/app-dir-module' import { isNotFoundError } from '../../client/components/not-found' import { @@ -50,25 +43,17 @@ import { getRedirectStatusCodeFromError } from '../../client/components/get-redi import { addImplicitTags, patchFetch } from '../lib/patch-fetch' import { AppRenderSpan } from '../lib/trace/constants' import { getTracer } from '../lib/trace/tracer' -import { interopDefault } from './interop-default' -import { preloadComponent } from './preload-component' import { FlightRenderResult } from './flight-render-result' -import { createErrorHandler } from './create-error-handler' +import { createErrorHandler, type ErrorHandler } from './create-error-handler' import { getShortDynamicParamType, dynamicParamTypes, } from './get-short-dynamic-param-type' import { getSegmentParam } from './get-segment-param' -import { getCssInlinedLinkTags } from './get-css-inlined-link-tags' -import { getPreloadableFonts } from './get-preloadable-fonts' import { getScriptNonceFromHeader } from './get-script-nonce-from-header' -import { renderToString } from './render-to-string' import { parseAndValidateFlightRouterState } from './parse-and-validate-flight-router-state' import { validateURL } from './validate-url' -import { - addSearchParamsIfPageSegment, - createFlightRouterStateFromLoaderTree, -} from './create-flight-router-state-from-loader-tree' +import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree' import { handleAction } from './action-handler' import { NEXT_DYNAMIC_NO_SSR_CODE } from '../../shared/lib/lazy-dynamic/no-ssr-error' import { warn } from '../../build/output/log' @@ -76,6 +61,12 @@ import { appendMutableCookies } from '../web/spec-extension/adapters/request-coo import { createServerInsertedHTML } from './server-inserted-html' import { getRequiredScripts } from './required-scripts' import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix' +import type { AppPageModule } from '../future/route-modules/app-page/module' +import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' +import { makeGetServerInsertedHTML } from './make-get-server-inserted-html' +import { walkTreeWithFlightRouterState } from './walk-tree-with-flight-router-state' +import { createComponentTree } from './create-component-tree' +import { getAssetQueryString } from './get-asset-query-string' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -87,6 +78,39 @@ export type GetDynamicParamFromSegment = ( type: DynamicParamTypesShort } | null +type AppRenderBaseContext = { + staticGenerationStore: StaticGenerationStore + requestStore: RequestStore + componentMod: AppPageModule + renderOpts: RenderOpts +} + +// TODO-APP: improve type +type ServerContext = [string, any] + +export type GenerateFlight = typeof generateFlight + +export type AppRenderContext = AppRenderBaseContext & { + getDynamicParamFromSegment: GetDynamicParamFromSegment + query: NextParsedUrlQuery + isPrefetch: boolean + providedSearchParams: NextParsedUrlQuery + requestTimestamp: number + searchParamsProps: { searchParams: NextParsedUrlQuery } + appUsingSizeAdjustment: boolean + providedFlightRouterState?: FlightRouterState + requestId: string + defaultRevalidate: StaticGenerationStore['revalidate'] + pagePath: string + clientReferenceManifest: ClientReferenceManifest + assetPrefix: string + serverContexts: ServerContext[] + flightDataRendererErrorHandler: ErrorHandler + serverComponentsErrorHandler: ErrorHandler + isNotFoundPath: boolean + res: ServerResponse +} + function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { // Align the segment with parallel-route-default in next-app-loader return ['', {}, loaderTree[2]] @@ -142,31 +166,233 @@ function findDynamicParamFromRouterState( return null } -function hasLoadingComponentInTree(tree: LoaderTree): boolean { - const [, parallelRoutes, { loading }] = tree +export type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath + +/** + * Returns a function that parses the dynamic segment and return the associated value. + */ +function makeGetDynamicParamFromSegment( + params: { [key: string]: any }, + providedFlightRouterState: FlightRouterState | undefined +): GetDynamicParamFromSegment { + return function getDynamicParamFromSegment( + // [slug] / [[slug]] / [...slug] + segment: string + ) { + const segmentParam = getSegmentParam(segment) + if (!segmentParam) { + return null + } + + const key = segmentParam.param + + let value = params[key] + + // this is a special marker that will be present for interception routes + if (value === '__NEXT_EMPTY_PARAM__') { + value = undefined + } + + if (Array.isArray(value)) { + value = value.map((i) => encodeURIComponent(i)) + } else if (typeof value === 'string') { + value = encodeURIComponent(value) + } + + if (!value) { + // Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard` + if (segmentParam.type === 'optional-catchall') { + const type = dynamicParamTypes[segmentParam.type] + return { + param: key, + value: null, + type: type, + // This value always has to be a string. + treeSegment: [key, '', type], + } + } + return findDynamicParamFromRouterState(providedFlightRouterState, segment) + } - if (loading) { - return true + const type = getShortDynamicParamType(segmentParam.type) + + return { + param: key, + // The value that is passed to user code. + value: value, + // The value that is rendered in the router tree. + treeSegment: [key, Array.isArray(value) ? value.join('/') : value, type], + type: type, + } + } +} + +// Handle Flight render request. This is only used when client-side navigating. E.g. when you `router.push('/dashboard')` or `router.reload()`. +async function generateFlight( + ctx: AppRenderContext, + options?: { + actionResult: ActionResult + skipFlight: boolean + asNotFound?: boolean + } +): Promise { + // Flight data that is going to be passed to the browser. + // Currently a single item array but in the future multiple patches might be combined in a single request. + let flightData: FlightData | null = null + + const { + componentMod: { tree: loaderTree, renderToReadableStream }, + getDynamicParamFromSegment, + appUsingSizeAdjustment, + staticGenerationStore: { urlPathname }, + providedSearchParams, + requestId, + providedFlightRouterState, + } = ctx + + if (!options?.skipFlight) { + const [MetadataTree, MetadataOutlet] = createMetadataComponents({ + tree: loaderTree, + pathname: urlPathname, + searchParams: providedSearchParams, + getDynamicParamFromSegment, + appUsingSizeAdjustment, + }) + flightData = ( + await walkTreeWithFlightRouterState({ + ctx, + createSegmentPath: (child) => child, + loaderTreeToFilter: loaderTree, + parentParams: {}, + flightRouterState: providedFlightRouterState, + isFirst: true, + // For flight, render metadata inside leaf page + rscPayloadHead: ( + // Adding requestId as react key to make metadata remount for each render + + ), + injectedCSS: new Set(), + injectedFontPreloadTags: new Set(), + rootLayoutIncluded: false, + asNotFound: ctx.isNotFoundPath || options?.asNotFound, + metadataOutlet: , + }) + ).map((path) => path.slice(1)) // remove the '' (root) segment } - return Object.values(parallelRoutes).some((parallelRoute) => - hasLoadingComponentInTree(parallelRoute) - ) as boolean + const buildIdFlightDataPair = [ctx.renderOpts.buildId, flightData] + + // For app dir, use the bundled version of Flight server renderer (renderToReadableStream) + // which contains the subset React. + const flightReadableStream = renderToReadableStream( + options + ? [options.actionResult, buildIdFlightDataPair] + : buildIdFlightDataPair, + ctx.clientReferenceManifest.clientModules, + { + context: ctx.serverContexts, + onError: ctx.flightDataRendererErrorHandler, + } + ).pipeThrough(createBufferedTransformStream()) + + return new FlightRenderResult(flightReadableStream) } -type AppRenderContext = { - staticGenerationStore: StaticGenerationStore - requestStore: RequestStore +/** + * A new React Component that renders the provided React Component + * using Flight which can then be rendered to HTML. + */ +function createServerComponentsRenderer( + ctx: AppRenderContext, + loaderTreeToRender: LoaderTree, + preinitScripts: () => void, + formState: null | any, + serverComponentsRenderOpts: any, + nonce: string | undefined +) { + return createServerComponentRenderer<{ + asNotFound: boolean + }>( + async (props) => { + preinitScripts() + // Create full component tree from root to leaf. + const injectedCSS = new Set() + const injectedFontPreloadTags = new Set() + const { + getDynamicParamFromSegment, + query, + providedSearchParams, + appUsingSizeAdjustment, + componentMod: { AppRouter, GlobalError }, + staticGenerationStore: { urlPathname }, + } = ctx + const initialTree = createFlightRouterStateFromLoaderTree( + loaderTreeToRender, + getDynamicParamFromSegment, + query + ) + + const [MetadataTree, MetadataOutlet] = createMetadataComponents({ + tree: loaderTreeToRender, + errorType: props.asNotFound ? 'not-found' : undefined, + pathname: urlPathname, + searchParams: providedSearchParams, + getDynamicParamFromSegment: getDynamicParamFromSegment, + appUsingSizeAdjustment: appUsingSizeAdjustment, + }) + + const { Component: ComponentTree, styles } = await createComponentTree({ + ctx, + createSegmentPath: (child) => child, + loaderTree: loaderTreeToRender, + parentParams: {}, + firstItem: true, + injectedCSS, + injectedFontPreloadTags, + rootLayoutIncluded: false, + asNotFound: props.asNotFound, + metadataOutlet: , + }) + + return ( + <> + {styles} + + {ctx.res.statusCode > 400 && ( + + )} + {/* Adding requestId as react key to make metadata remount for each render */} + + + } + globalErrorComponent={GlobalError} + > + + + + ) + }, + ctx.componentMod, + { ...serverComponentsRenderOpts, formState }, + ctx.serverComponentsErrorHandler, + nonce + ) } -const wrappedRender = async ( +async function renderToHTMLOrFlightImpl( req: IncomingMessage, res: ServerResponse, pagePath: string, query: NextParsedUrlQuery, renderOpts: RenderOpts, - ctx: AppRenderContext -) => { + baseCtx: AppRenderBaseContext +) { const isFlight = req.headers[RSC.toLowerCase()] !== undefined const isNotFoundPath = pagePath === '/404' @@ -174,7 +400,7 @@ const wrappedRender = async ( // consistent and won't change during this request. This is important to // avoid that resources can be deduped by React Float if the same resource is // rendered or preloaded multiple times: ``. - const DEV_REQUEST_TS = Date.now() + const requestTimestamp = Date.now() const { buildManifest, @@ -184,10 +410,8 @@ const wrappedRender = async ( dev, nextFontManifest, supportsDynamicHTML, - nextConfigOutput, serverActionsBodySizeLimit, buildId, - deploymentId, appDirDevErrorLogger, assetPrefix = '', } = renderOpts @@ -204,7 +428,7 @@ const wrappedRender = async ( const extraRenderResultMeta: RenderResultMetadata = {} - const appUsingSizeAdjust = !!nextFontManifest?.appUsingSizeAdjust + const appUsingSizeAdjustment = !!nextFontManifest?.appUsingSizeAdjust // TODO: fix this typescript const clientReferenceManifest = renderOpts.clientReferenceManifest! @@ -254,23 +478,13 @@ const wrappedRender = async ( // Pull out the hooks/references from the component. const { - staticGenerationBailout, - LayoutRouter, - RenderFromTemplateContext, createSearchParamsBailoutProxy, - StaticGenerationSearchParamsBailoutProvider, - serverHooks: { DynamicServerError }, - NotFoundBoundary, - renderToReadableStream, AppRouter, GlobalError, tree: loaderTree, - preloadFont, - preconnect, - preloadStyle, } = ComponentMod - const { staticGenerationStore, requestStore } = ctx + const { staticGenerationStore, requestStore } = baseCtx const { urlPathname } = staticGenerationStore staticGenerationStore.fetchMetrics = [] @@ -296,7 +510,6 @@ const wrappedRender = async ( * The metadata items array created in next-app-loader with all relevant information * that we need to resolve the final metadata. */ - let requestId: string if (process.env.NEXT_RUNTIME === 'edge') { @@ -317,955 +530,44 @@ const wrappedRender = async ( * It has to hold values that can't change while rendering from the common layout down. * An example of this would be that `headers` are available but `searchParams` are not because that'd mean we have to render from the root layout down on all requests. */ - const serverContexts: Array<[string, any]> = [ ['WORKAROUND', null], // TODO-APP: First value has a bug currently where the value is not set on the second request: https://github.com/facebook/react/issues/24849 ] - type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath - /** * Dynamic parameters. E.g. when you visit `/dashboard/vercel` which is rendered by `/dashboard/[slug]` the value will be {"slug": "vercel"}. */ const params = renderOpts.params ?? {} - /** - * Parse the dynamic segment and return the associated value. - */ - const getDynamicParamFromSegment: GetDynamicParamFromSegment = ( - // [slug] / [[slug]] / [...slug] - segment: string - ) => { - const segmentParam = getSegmentParam(segment) - if (!segmentParam) { - return null - } - - const key = segmentParam.param - - let value = params[key] - - // this is a special marker that will be present for interception routes - if (value === '__NEXT_EMPTY_PARAM__') { - value = undefined - } - - if (Array.isArray(value)) { - value = value.map((i) => encodeURIComponent(i)) - } else if (typeof value === 'string') { - value = encodeURIComponent(value) - } - - if (!value) { - // Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard` - if (segmentParam.type === 'optional-catchall') { - const type = dynamicParamTypes[segmentParam.type] - return { - param: key, - value: null, - type: type, - // This value always has to be a string. - treeSegment: [key, '', type], - } - } - return findDynamicParamFromRouterState(providedFlightRouterState, segment) - } - - const type = getShortDynamicParamType(segmentParam.type) - - return { - param: key, - // The value that is passed to user code. - value: value, - // The value that is rendered in the router tree. - treeSegment: [key, Array.isArray(value) ? value.join('/') : value, type], - type: type, - } - } - - let defaultRevalidate: false | undefined | number = false - - const getAssetQueryString = (addTimestamp: boolean) => { - const isDev = process.env.NODE_ENV === 'development' - let qs = '' - - if (isDev && addTimestamp) { - qs += `?v=${DEV_REQUEST_TS}` - } - - if (deploymentId) { - qs += `${isDev ? '&' : '?'}dpl=${deploymentId}` - } - return qs - } - - const createComponentAndStyles = async ({ - filePath, - getComponent, - injectedCSS, - }: { - filePath: string - getComponent: () => any - injectedCSS: Set - }): Promise => { - const cssHrefs = getCssInlinedLinkTags( - clientReferenceManifest, - filePath, - injectedCSS - ) - - const styles = cssHrefs - ? cssHrefs.map((href, index) => { - // In dev, Safari and Firefox will cache the resource during HMR: - // - https://github.com/vercel/next.js/issues/5860 - // - https://bugs.webkit.org/show_bug.cgi?id=187726 - // Because of this, we add a `?v=` query to bypass the cache during - // development. We need to also make sure that the number is always - // increasing. - const fullHref = `${assetPrefix}/_next/${href}${getAssetQueryString( - true - )}` - - // `Precedence` is an opt-in signal for React to handle resource - // loading and deduplication, etc. It's also used as the key to sort - // resources so they will be injected in the correct order. - // During HMR, it's critical to use different `precedence` values - // for different stylesheets, so their order will be kept. - // https://github.com/facebook/react/pull/25060 - const precedence = - process.env.NODE_ENV === 'development' ? 'next_' + href : 'next' - - return ( - - ) - }) - : null - - const Comp = interopDefault(await getComponent()) - - return [Comp, styles] - } - - const getLayerAssets = ({ - layoutOrPagePath, - injectedCSS: injectedCSSWithCurrentLayout, - injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout, - }: { - layoutOrPagePath: string | undefined - injectedCSS: Set - injectedFontPreloadTags: Set - }): React.ReactNode => { - const stylesheets: string[] = layoutOrPagePath - ? getCssInlinedLinkTags( - clientReferenceManifest, - layoutOrPagePath, - injectedCSSWithCurrentLayout, - true - ) - : [] - - const preloadedFontFiles = layoutOrPagePath - ? getPreloadableFonts( - nextFontManifest, - layoutOrPagePath, - injectedFontPreloadTagsWithCurrentLayout - ) - : null - - if (preloadedFontFiles) { - if (preloadedFontFiles.length) { - for (let i = 0; i < preloadedFontFiles.length; i++) { - const fontFilename = preloadedFontFiles[i] - const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(fontFilename)![1] - const type = `font/${ext}` - const href = `${assetPrefix}/_next/${fontFilename}` - preloadFont(href, type, renderOpts.crossOrigin) - } - } else { - try { - let url = new URL(assetPrefix) - preconnect(url.origin, 'anonymous') - } catch (error) { - // assetPrefix must not be a fully qualified domain name. We assume - // we should preconnect to same origin instead - preconnect('/', 'anonymous') - } - } - } - - const styles = stylesheets - ? stylesheets.map((href, index) => { - // In dev, Safari and Firefox will cache the resource during HMR: - // - https://github.com/vercel/next.js/issues/5860 - // - https://bugs.webkit.org/show_bug.cgi?id=187726 - // Because of this, we add a `?v=` query to bypass the cache during - // development. We need to also make sure that the number is always - // increasing. - const fullHref = `${assetPrefix}/_next/${href}${getAssetQueryString( - true - )}` - - // `Precedence` is an opt-in signal for React to handle resource - // loading and deduplication, etc. It's also used as the key to sort - // resources so they will be injected in the correct order. - // During HMR, it's critical to use different `precedence` values - // for different stylesheets, so their order will be kept. - // https://github.com/facebook/react/pull/25060 - const precedence = - process.env.NODE_ENV === 'development' ? 'next_' + href : 'next' - - preloadStyle(fullHref, renderOpts.crossOrigin) - - return ( - - ) - }) - : null - - return styles - } - - const parseLoaderTree = (tree: LoaderTree) => { - const [segment, parallelRoutes, components] = tree - const { layout } = components - let { page } = components - // a __DEFAULT__ segment means that this route didn't match any of the - // segments in the route, so we should use the default page - - page = segment === '__DEFAULT__' ? components.defaultPage : page - - const layoutOrPagePath = layout?.[1] || page?.[1] - - return { - page, - segment, - components, - layoutOrPagePath, - parallelRoutes, - } - } - - /** - * Use the provided loader tree to create the React Component tree. - */ - const createComponentTree = async ({ - createSegmentPath, - loaderTree: tree, - parentParams, - firstItem, - rootLayoutIncluded, - injectedCSS, - injectedFontPreloadTags, - asNotFound, - metadataOutlet, - }: { - createSegmentPath: CreateSegmentPath - loaderTree: LoaderTree - parentParams: { [key: string]: any } - rootLayoutIncluded: boolean - firstItem?: boolean - injectedCSS: Set - injectedFontPreloadTags: Set - asNotFound?: boolean - metadataOutlet?: React.ReactNode - }): Promise<{ - Component: React.ComponentType - styles: React.ReactNode - }> => { - const { page, layoutOrPagePath, segment, components, parallelRoutes } = - parseLoaderTree(tree) - - const { - layout, - template, - error, - loading, - 'not-found': notFound, - } = components - - const injectedCSSWithCurrentLayout = new Set(injectedCSS) - const injectedFontPreloadTagsWithCurrentLayout = new Set( - injectedFontPreloadTags - ) - - const styles = getLayerAssets({ - layoutOrPagePath, - injectedCSS: injectedCSSWithCurrentLayout, - injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout, - }) - - const [Template, templateStyles] = template - ? await createComponentAndStyles({ - filePath: template[1], - getComponent: template[0], - injectedCSS: injectedCSSWithCurrentLayout, - }) - : [React.Fragment] - - const [ErrorComponent, errorStyles] = error - ? await createComponentAndStyles({ - filePath: error[1], - getComponent: error[0], - injectedCSS: injectedCSSWithCurrentLayout, - }) - : [] - - const [Loading, loadingStyles] = loading - ? await createComponentAndStyles({ - filePath: loading[1], - getComponent: loading[0], - injectedCSS: injectedCSSWithCurrentLayout, - }) - : [] - - const isLayout = typeof layout !== 'undefined' - const isPage = typeof page !== 'undefined' - const [layoutOrPageMod] = await getLayoutOrPageModule(tree) - - /** - * Checks if the current segment is a root layout. - */ - const rootLayoutAtThisLevel = isLayout && !rootLayoutIncluded - /** - * Checks if the current segment or any level above it has a root layout. - */ - const rootLayoutIncludedAtThisLevelOrAbove = - rootLayoutIncluded || rootLayoutAtThisLevel - - const [NotFound, notFoundStyles] = notFound - ? await createComponentAndStyles({ - filePath: notFound[1], - getComponent: notFound[0], - injectedCSS: injectedCSSWithCurrentLayout, - }) - : [] - - let dynamic = layoutOrPageMod?.dynamic - - if (nextConfigOutput === 'export') { - if (!dynamic || dynamic === 'auto') { - dynamic = 'error' - } else if (dynamic === 'force-dynamic') { - staticGenerationStore.forceDynamic = true - staticGenerationStore.dynamicShouldError = true - staticGenerationBailout(`output: export`, { - dynamic, - link: 'https://nextjs.org/docs/advanced-features/static-html-export', - }) - } - } - - if (typeof dynamic === 'string') { - // the nested most config wins so we only force-static - // if it's configured above any parent that configured - // otherwise - if (dynamic === 'error') { - staticGenerationStore.dynamicShouldError = true - } else if (dynamic === 'force-dynamic') { - staticGenerationStore.forceDynamic = true - staticGenerationBailout(`force-dynamic`, { dynamic }) - } else { - staticGenerationStore.dynamicShouldError = false - if (dynamic === 'force-static') { - staticGenerationStore.forceStatic = true - } else { - staticGenerationStore.forceStatic = false - } - } - } - - if (typeof layoutOrPageMod?.fetchCache === 'string') { - staticGenerationStore.fetchCache = layoutOrPageMod?.fetchCache - } - - if (typeof layoutOrPageMod?.revalidate === 'number') { - defaultRevalidate = layoutOrPageMod.revalidate as number - - if ( - typeof staticGenerationStore.revalidate === 'undefined' || - (typeof staticGenerationStore.revalidate === 'number' && - staticGenerationStore.revalidate > defaultRevalidate) - ) { - staticGenerationStore.revalidate = defaultRevalidate - } - - if (staticGenerationStore.isStaticGeneration && defaultRevalidate === 0) { - const dynamicUsageDescription = `revalidate: 0 configured ${segment}` - staticGenerationStore.dynamicUsageDescription = dynamicUsageDescription - - throw new DynamicServerError(dynamicUsageDescription) - } - } - - if (staticGenerationStore?.dynamicUsageErr) { - throw staticGenerationStore.dynamicUsageErr - } - - const LayoutOrPage = layoutOrPageMod - ? interopDefault(layoutOrPageMod) - : undefined - - /** - * The React Component to render. - */ - let Component = LayoutOrPage - const parallelKeys = Object.keys(parallelRoutes) - const hasSlotKey = parallelKeys.length > 1 - - if (hasSlotKey && rootLayoutAtThisLevel) { - Component = (componentProps: any) => { - const NotFoundComponent = NotFound - const RootLayoutComponent = LayoutOrPage - return ( - - {styles} - - {notFoundStyles} - - - - } - > - - - ) - } - } - - if (dev) { - const { isValidElementType } = require('next/dist/compiled/react-is') - if ( - (isPage || typeof Component !== 'undefined') && - !isValidElementType(Component) - ) { - throw new Error( - `The default export is not a React Component in page: "${pagePath}"` - ) - } - - if ( - typeof ErrorComponent !== 'undefined' && - !isValidElementType(ErrorComponent) - ) { - throw new Error( - `The default export of error is not a React Component in page: ${segment}` - ) - } - - if (typeof Loading !== 'undefined' && !isValidElementType(Loading)) { - throw new Error( - `The default export of loading is not a React Component in ${segment}` - ) - } - - if (typeof NotFound !== 'undefined' && !isValidElementType(NotFound)) { - throw new Error( - `The default export of notFound is not a React Component in ${segment}` - ) - } - } - - // Handle dynamic segment params. - const segmentParam = getDynamicParamFromSegment(segment) - /** - * Create object holding the parent params and current params - */ - const currentParams = - // Handle null case where dynamic param is optional - segmentParam && segmentParam.value !== null - ? { - ...parentParams, - [segmentParam.param]: segmentParam.value, - } - : // Pass through parent params to children - parentParams - // Resolve the segment param - const actualSegment = segmentParam ? segmentParam.treeSegment : segment - - // This happens outside of rendering in order to eagerly kick off data fetching for layouts / the page further down - const parallelRouteMap = await Promise.all( - Object.keys(parallelRoutes).map( - async (parallelRouteKey): Promise<[string, React.ReactNode]> => { - const isChildrenRouteKey = parallelRouteKey === 'children' - const currentSegmentPath: FlightSegmentPath = firstItem - ? [parallelRouteKey] - : [actualSegment, parallelRouteKey] - - const parallelRoute = parallelRoutes[parallelRouteKey] - - const childSegment = parallelRoute[0] - const childSegmentParam = getDynamicParamFromSegment(childSegment) - const notFoundComponent = - NotFound && isChildrenRouteKey ? : undefined - - function getParallelRoutePair( - currentChildProp: ChildProp, - currentStyles: React.ReactNode - ): [string, React.ReactNode] { - // This is turned back into an object below. - return [ - parallelRouteKey, - : undefined} - loadingStyles={loadingStyles} - // TODO-APP: Add test for loading returning `undefined`. This currently can't be tested as the `webdriver()` tab will wait for the full page to load before returning. - hasLoading={Boolean(Loading)} - error={ErrorComponent} - errorStyles={errorStyles} - template={ - - } - templateStyles={templateStyles} - notFound={notFoundComponent} - notFoundStyles={notFoundStyles} - childProp={currentChildProp} - styles={currentStyles} - />, - ] - } - - // if we're prefetching and that there's a Loading component, we bail out - // otherwise we keep rendering for the prefetch. - // We also want to bail out if there's no Loading component in the tree. - let currentStyles = undefined - let childElement = null - const childPropSegment = addSearchParamsIfPageSegment( - childSegmentParam ? childSegmentParam.treeSegment : childSegment, - query - ) - if ( - !( - isPrefetch && - (Loading || !hasLoadingComponentInTree(parallelRoute)) - ) - ) { - // Create the child component - const { Component: ChildComponent, styles: childComponentStyles } = - await createComponentTree({ - createSegmentPath: (child) => { - return createSegmentPath([...currentSegmentPath, ...child]) - }, - loaderTree: parallelRoute, - parentParams: currentParams, - rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, - injectedCSS: injectedCSSWithCurrentLayout, - injectedFontPreloadTags: - injectedFontPreloadTagsWithCurrentLayout, - asNotFound, - metadataOutlet, - }) - - currentStyles = childComponentStyles - childElement = - } - - const childProp: ChildProp = { - current: childElement, - segment: childPropSegment, - } - - return getParallelRoutePair(childProp, currentStyles) - } - ) - ) - - // Convert the parallel route map into an object after all promises have been resolved. - const parallelRouteComponents = parallelRouteMap.reduce( - (list, [parallelRouteKey, Comp]) => { - list[parallelRouteKey] = Comp - return list - }, - {} as { [key: string]: React.ReactNode } - ) - - // When the segment does not have a layout or page we still have to add the layout router to ensure the path holds the loading component - if (!Component) { - return { - Component: () => <>{parallelRouteComponents.children}, - styles, - } - } - - const isClientComponent = isClientReference(layoutOrPageMod) - - // If it's a not found route, and we don't have any matched parallel - // routes, we try to render the not found component if it exists. - let notFoundComponent = {} - if ( - NotFound && - asNotFound && - // In development, it could hit the parallel-route-default not found, so we only need to check the segment. - // Or if there's no parallel routes means it reaches the end. - !parallelRouteMap.length - ) { - notFoundComponent = { - children: ( - <> - - {process.env.NODE_ENV === 'development' && ( - - )} - {notFoundStyles} - - - ), - } - } - - const props = { - ...parallelRouteComponents, - ...notFoundComponent, - // TODO-APP: params and query have to be blocked parallel route names. Might have to add a reserved name list. - // Params are always the current params that apply to the layout - // If you have a `/dashboard/[team]/layout.js` it will provide `team` as a param but not anything further down. - params: currentParams, - // Query is only provided to page - ...(() => { - if (isClientComponent && isStaticGeneration) { - return {} - } - - if (isPage) { - return searchParamsProps - } - })(), - } - - // Eagerly execute layout/page component to trigger fetches early. - if (!isClientComponent) { - Component = await Promise.resolve().then(() => - preloadComponent(Component, props) - ) - } - - return { - Component: () => { - return ( - <> - {isPage ? metadataOutlet : null} - {/* needs to be the first element because we use `findDOMNode` in layout router to locate it. */} - {isPage && isClientComponent ? ( - - ) : ( - - )} - {/* This null is currently critical. The wrapped Component can render null and if there was not fragment - surrounding it this would look like a pending tree data state on the client which will cause an errror - and break the app. Long-term we need to move away from using null as a partial tree identifier since it - is a valid return type for the components we wrap. Once we make this change we can safely remove the - fragment. The reason the extra null here is required is that fragments which only have 1 child are elided. - If the Component above renders null the actual treedata will look like `[null, null]`. If we remove the extra - null it will look like `null` (the array is elided) and this is what confuses the client router. - TODO-APP update router to use a Symbol for partial tree detection */} - {null} - - ) - }, - styles, - } - } - - // Handle Flight render request. This is only used when client-side navigating. E.g. when you `router.push('/dashboard')` or `router.reload()`. - const generateFlight = async (options?: { - actionResult: ActionResult - skipFlight: boolean - asNotFound?: boolean - }): Promise => { - /** - * Use router state to decide at what common layout to render the page. - * This can either be the common layout between two pages or a specific place to start rendering from using the "refetch" marker in the tree. - */ - const walkTreeWithFlightRouterState = async ({ - createSegmentPath, - loaderTreeToFilter, - parentParams, - isFirst, - flightRouterState, - parentRendered, - rscPayloadHead, - injectedCSS, - injectedFontPreloadTags, - rootLayoutIncluded, - asNotFound, - metadataOutlet, - }: { - createSegmentPath: CreateSegmentPath - loaderTreeToFilter: LoaderTree - parentParams: { [key: string]: string | string[] } - isFirst: boolean - flightRouterState?: FlightRouterState - parentRendered?: boolean - rscPayloadHead: React.ReactNode - injectedCSS: Set - injectedFontPreloadTags: Set - rootLayoutIncluded: boolean - asNotFound?: boolean - metadataOutlet: React.ReactNode - }): Promise => { - const [segment, parallelRoutes, components] = loaderTreeToFilter - - const parallelRoutesKeys = Object.keys(parallelRoutes) - - const { layout } = components - const isLayout = typeof layout !== 'undefined' - - /** - * Checks if the current segment is a root layout. - */ - const rootLayoutAtThisLevel = isLayout && !rootLayoutIncluded - /** - * Checks if the current segment or any level above it has a root layout. - */ - const rootLayoutIncludedAtThisLevelOrAbove = - rootLayoutIncluded || rootLayoutAtThisLevel - - // Because this function walks to a deeper point in the tree to start rendering we have to track the dynamic parameters up to the point where rendering starts - const segmentParam = getDynamicParamFromSegment(segment) - const currentParams = - // Handle null case where dynamic param is optional - segmentParam && segmentParam.value !== null - ? { - ...parentParams, - [segmentParam.param]: segmentParam.value, - } - : parentParams - const actualSegment: Segment = addSearchParamsIfPageSegment( - segmentParam ? segmentParam.treeSegment : segment, - query - ) - - /** - * Decide if the current segment is where rendering has to start. - */ - const renderComponentsOnThisLevel = - // No further router state available - !flightRouterState || - // Segment in router state does not match current segment - !matchSegment(actualSegment, flightRouterState[0]) || - // Last item in the tree - parallelRoutesKeys.length === 0 || - // Explicit refresh - flightRouterState[3] === 'refetch' - - const shouldSkipComponentTree = - isPrefetch && - !Boolean(components.loading) && - (flightRouterState || - // If there is no flightRouterState, we need to check the entire loader tree, as otherwise we'll be only checking the root - !hasLoadingComponentInTree(loaderTree)) - - if (!parentRendered && renderComponentsOnThisLevel) { - const overriddenSegment = - flightRouterState && - canSegmentBeOverridden(actualSegment, flightRouterState[0]) - ? flightRouterState[0] - : null - - return [ - [ - overriddenSegment ?? actualSegment, - createFlightRouterStateFromLoaderTree( - // Create router state using the slice of the loaderTree - loaderTreeToFilter, - getDynamicParamFromSegment, - query - ), - shouldSkipComponentTree - ? null - : // Create component tree using the slice of the loaderTree - // @ts-expect-error TODO-APP: fix async component type - React.createElement(async () => { - const { Component } = await createComponentTree( - // This ensures flightRouterPath is valid and filters down the tree - { - createSegmentPath, - loaderTree: loaderTreeToFilter, - parentParams: currentParams, - firstItem: isFirst, - injectedCSS, - injectedFontPreloadTags, - // This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too. - rootLayoutIncluded, - asNotFound, - metadataOutlet, - } - ) - - return - }), - shouldSkipComponentTree - ? null - : (() => { - const { layoutOrPagePath } = - parseLoaderTree(loaderTreeToFilter) - - const styles = getLayerAssets({ - layoutOrPagePath, - injectedCSS: new Set(injectedCSS), - injectedFontPreloadTags: new Set(injectedFontPreloadTags), - }) - - return ( - <> - {styles} - {rscPayloadHead} - - ) - })(), - ], - ] - } - - // If we are not rendering on this level we need to check if the current - // segment has a layout. If so, we need to track all the used CSS to make - // the result consistent. - const layoutPath = layout?.[1] - const injectedCSSWithCurrentLayout = new Set(injectedCSS) - const injectedFontPreloadTagsWithCurrentLayout = new Set( - injectedFontPreloadTags - ) - if (layoutPath) { - getCssInlinedLinkTags( - clientReferenceManifest, - layoutPath, - injectedCSSWithCurrentLayout, - true - ) - getPreloadableFonts( - nextFontManifest, - layoutPath, - injectedFontPreloadTagsWithCurrentLayout - ) - } - - // Walk through all parallel routes. - const paths: FlightDataPath[] = ( - await Promise.all( - parallelRoutesKeys.map(async (parallelRouteKey) => { - // for (const parallelRouteKey of parallelRoutesKeys) { - const parallelRoute = parallelRoutes[parallelRouteKey] - - const currentSegmentPath: FlightSegmentPath = isFirst - ? [parallelRouteKey] - : [actualSegment, parallelRouteKey] - - const path = await walkTreeWithFlightRouterState({ - createSegmentPath: (child) => { - return createSegmentPath([...currentSegmentPath, ...child]) - }, - loaderTreeToFilter: parallelRoute, - parentParams: currentParams, - flightRouterState: - flightRouterState && flightRouterState[1][parallelRouteKey], - parentRendered: parentRendered || renderComponentsOnThisLevel, - isFirst: false, - rscPayloadHead, - injectedCSS: injectedCSSWithCurrentLayout, - injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout, - rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, - asNotFound, - metadataOutlet, - }) - - return path - .map((item) => { - // we don't need to send over default routes in the flight data - // because they are always ignored by the client, unless it's a refetch - if ( - item[0] === '__DEFAULT__' && - flightRouterState && - !!flightRouterState[1][parallelRouteKey][0] && - flightRouterState[1][parallelRouteKey][3] !== 'refetch' - ) { - return null - } - return [actualSegment, parallelRouteKey, ...item] - }) - .filter(Boolean) as FlightDataPath[] - }) - ) - ).flat() - - return paths - } - - // Flight data that is going to be passed to the browser. - // Currently a single item array but in the future multiple patches might be combined in a single request. - - let flightData: FlightData | null = null - if (!options?.skipFlight) { - const [MetadataTree, MetadataOutlet] = createMetadataComponents({ - tree: loaderTree, - pathname: urlPathname, - searchParams: providedSearchParams, - getDynamicParamFromSegment, - appUsingSizeAdjust, - }) - flightData = ( - await walkTreeWithFlightRouterState({ - createSegmentPath: (child) => child, - loaderTreeToFilter: loaderTree, - parentParams: {}, - flightRouterState: providedFlightRouterState, - isFirst: true, - // For flight, render metadata inside leaf page - rscPayloadHead: ( - // Adding requestId as react key to make metadata remount for each render - - ), - injectedCSS: new Set(), - injectedFontPreloadTags: new Set(), - rootLayoutIncluded: false, - asNotFound: isNotFoundPath || options?.asNotFound, - metadataOutlet: , - }) - ).map((path) => path.slice(1)) // remove the '' (root) segment - } - - const buildIdFlightDataPair = [buildId, flightData] - - // For app dir, use the bundled version of Flight server renderer (renderToReadableStream) - // which contains the subset React. - const flightReadableStream = renderToReadableStream( - options - ? [options.actionResult, buildIdFlightDataPair] - : buildIdFlightDataPair, - clientReferenceManifest.clientModules, - { - context: serverContexts, - onError: flightDataRendererErrorHandler, - } - ).pipeThrough(createBufferedTransformStream()) + const getDynamicParamFromSegment = makeGetDynamicParamFromSegment( + params, + providedFlightRouterState + ) - return new FlightRenderResult(flightReadableStream) + const ctx: AppRenderContext = { + ...baseCtx, + getDynamicParamFromSegment, + query, + isPrefetch, + providedSearchParams, + requestTimestamp, + searchParamsProps, + appUsingSizeAdjustment, + providedFlightRouterState, + requestId, + defaultRevalidate: false, + pagePath, + clientReferenceManifest, + assetPrefix, + flightDataRendererErrorHandler, + serverComponentsErrorHandler, + serverContexts, + isNotFoundPath, + res, } if (isFlight && !staticGenerationStore.isStaticGeneration) { - return generateFlight() + return generateFlight(ctx) } // Get the nonce from the incoming request if it has one. @@ -1284,91 +586,15 @@ const wrappedRender = async ( const validateRootLayout = dev ? { - validateRootLayout: { - assetPrefix: renderOpts.assetPrefix, - getTree: () => - createFlightRouterStateFromLoaderTree( - loaderTree, - getDynamicParamFromSegment, - query - ), - }, + assetPrefix: renderOpts.assetPrefix, + getTree: () => + createFlightRouterStateFromLoaderTree( + loaderTree, + getDynamicParamFromSegment, + query + ), } - : {} - - /** - * A new React Component that renders the provided React Component - * using Flight which can then be rendered to HTML. - */ - const createServerComponentsRenderer = ( - loaderTreeToRender: LoaderTree, - preinitScripts: () => void, - formState: null | any - ) => - createServerComponentRenderer<{ - asNotFound: boolean - }>( - async (props) => { - preinitScripts() - // Create full component tree from root to leaf. - const injectedCSS = new Set() - const injectedFontPreloadTags = new Set() - const initialTree = createFlightRouterStateFromLoaderTree( - loaderTreeToRender, - getDynamicParamFromSegment, - query - ) - - const [MetadataTree, MetadataOutlet] = createMetadataComponents({ - tree: loaderTreeToRender, - errorType: props.asNotFound ? 'not-found' : undefined, - pathname: urlPathname, - searchParams: providedSearchParams, - getDynamicParamFromSegment: getDynamicParamFromSegment, - appUsingSizeAdjust: appUsingSizeAdjust, - }) - - const { Component: ComponentTree, styles } = await createComponentTree({ - createSegmentPath: (child) => child, - loaderTree: loaderTreeToRender, - parentParams: {}, - firstItem: true, - injectedCSS, - injectedFontPreloadTags, - rootLayoutIncluded: false, - asNotFound: props.asNotFound, - metadataOutlet: , - }) - - return ( - <> - {styles} - - {res.statusCode > 400 && ( - - )} - {/* Adding requestId as react key to make metadata remount for each render */} - - - } - globalErrorComponent={GlobalError} - > - - - - ) - }, - ComponentMod, - { ...serverComponentsRenderOpts, formState }, - serverComponentsErrorHandler, - nonce - ) + : undefined const { HeadManagerContext } = require('../../shared/lib/head-manager-context.shared-runtime') as typeof import('../../shared/lib/head-manager-context.shared-runtime') @@ -1410,6 +636,7 @@ const wrappedRender = async ( ) .map((polyfill) => ({ src: `${assetPrefix}/_next/${polyfill}${getAssetQueryString( + ctx, false )}`, integrity: subresourceIntegrityManifest?.[polyfill], @@ -1423,13 +650,16 @@ const wrappedRender = async ( assetPrefix, renderOpts.crossOrigin, subresourceIntegrityManifest, - getAssetQueryString(true), + getAssetQueryString(ctx, true), nonce ) const ServerComponentsRenderer = createServerComponentsRenderer( + ctx, tree, preinitScripts, - formState + formState, + serverComponentsRenderOpts, + nonce ) const content = ( ) - let polyfillsFlushed = false - let flushedErrorMetaTagsUntilIndex = 0 - const getServerInsertedHTML = (serverCapturedErrors: Error[]) => { - // Loop through all the errors that have been captured but not yet - // flushed. - const errorMetaTags = [] - for ( - ; - flushedErrorMetaTagsUntilIndex < serverCapturedErrors.length; - flushedErrorMetaTagsUntilIndex++ - ) { - const error = serverCapturedErrors[flushedErrorMetaTagsUntilIndex] - - if (isNotFoundError(error)) { - errorMetaTags.push( - , - process.env.NODE_ENV === 'development' ? ( - - ) : null - ) - } else if (isRedirectError(error)) { - const redirectUrl = getURLFromRedirectError(error) - const isPermanent = - getRedirectStatusCodeFromError(error) === 308 ? true : false - if (redirectUrl) { - errorMetaTags.push( - - ) - } - } - } - - const flushed = renderToString({ - ReactDOMServer: require('react-dom/server.edge'), - element: ( - <> - {polyfillsFlushed - ? null - : polyfills?.map((polyfill) => { - return