diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index cd766044fe0223..b97c78248a6185 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -73,6 +73,7 @@ jobs: run: echo STORE_PATH=$(pnpm store path) >> $GITHUB_OUTPUT - uses: actions/cache@v3 + timeout-minutes: 2 id: cache-pnpm-store with: path: ${{ steps.get-store-path.outputs.STORE_PATH }} @@ -101,6 +102,7 @@ jobs: run: echo "WEEK=$(date +%U)" >> $GITHUB_OUTPUT - uses: actions/cache@v3 + timeout-minutes: 2 id: cache-build with: path: ./* @@ -119,6 +121,7 @@ jobs: - run: npm i -g pnpm@${PNPM_VERSION} - uses: actions/cache@v3 + timeout-minutes: 2 id: restore-build with: path: ./* @@ -131,6 +134,7 @@ jobs: needs: build steps: - uses: actions/cache@v3 + timeout-minutes: 2 id: restore-build if: ${{needs.build.outputs.docsChange == 'nope'}} with: @@ -152,6 +156,7 @@ jobs: - name: Cache cargo registry uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.swc-change.outputs.SWC_CHANGE == 'yup' }} with: path: ~/.cargo/registry @@ -159,6 +164,7 @@ jobs: - name: Cache cargo index uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.swc-change.outputs.SWC_CHANGE == 'yup' }} with: path: ~/.cargo/git @@ -197,6 +203,7 @@ jobs: if: ${{needs.build.outputs.docsChange == 'nope'}} - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -233,6 +240,7 @@ jobs: check-latest: true - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -272,6 +280,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -332,6 +341,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -391,6 +401,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -441,6 +452,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -486,6 +498,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ needs.build.outputs.docsChange == 'nope' }} id: restore-build with: @@ -561,6 +574,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -616,6 +630,7 @@ jobs: check-latest: true - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -663,6 +678,7 @@ jobs: NEXT_TELEMETRY_DISABLED: 1 steps: - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -691,6 +707,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -715,6 +732,7 @@ jobs: NEXT_TELEMETRY_DISABLED: 1 steps: - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -754,6 +772,7 @@ jobs: run: sudo ethtool -K eth0 tx off rx off - uses: actions/cache@v3 + timeout-minutes: 2 id: restore-build with: path: ./* @@ -784,6 +803,7 @@ jobs: VERCEL_TEST_TEAM: vtest314-next-e2e-tests steps: - uses: actions/cache@v3 + timeout-minutes: 2 id: restore-build with: path: ./* @@ -823,6 +843,7 @@ jobs: check-latest: true - uses: actions/cache@v3 + timeout-minutes: 2 id: restore-build with: path: ./* @@ -857,6 +878,7 @@ jobs: - name: Cache cargo registry uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.docs-change.outputs.DOCS_CHANGE == 'nope' }} with: path: ~/.cargo/registry @@ -864,6 +886,7 @@ jobs: - name: Cache cargo index uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.docs-change.outputs.DOCS_CHANGE == 'nope' }} with: path: ~/.cargo/git @@ -876,6 +899,7 @@ jobs: - name: Turbo Cache id: turbo-cache uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.docs-change.outputs.DOCS_CHANGE == 'nope' }} with: path: .turbo @@ -895,6 +919,7 @@ jobs: # So we get latest cache - name: Cache built files uses: actions/cache@v3 + timeout-minutes: 2 with: path: ./packages/next-swc/target key: next-swc-cargo-cache-dev-ubuntu-latest-${{ hashFiles('**/Cargo.lock') }} @@ -962,6 +987,7 @@ jobs: steps: - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -1146,6 +1172,7 @@ jobs: # So we get latest cache - name: Cache built files uses: actions/cache@v3 + timeout-minutes: 2 with: path: ./packages/next-swc/target key: next-swc-cargo-cache-${{ matrix.settings.target }}--${{ hashFiles('**/Cargo.lock') }} @@ -1170,12 +1197,14 @@ jobs: - name: Cache cargo registry uses: actions/cache@v3 + timeout-minutes: 2 with: path: ~/.cargo/registry key: ${{ matrix.settings.target }}-cargo-registry - name: Cache cargo index uses: actions/cache@v3 + timeout-minutes: 2 with: path: ~/.cargo/git key: ${{ matrix.settings.target }}-cargo-index @@ -1354,6 +1383,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} id: restore-build with: @@ -1381,6 +1411,7 @@ jobs: - name: Turbo Cache id: turbo-cache uses: actions/cache@v3 + timeout-minutes: 2 if: ${{needs.build.outputs.docsChange == 'nope'}} with: path: .turbo @@ -1440,6 +1471,7 @@ jobs: check-latest: true - uses: actions/cache@v3 + timeout-minutes: 2 id: restore-build if: ${{needs.build.outputs.docsChange == 'nope'}} with: diff --git a/.github/workflows/pull_request_stats.yml b/.github/workflows/pull_request_stats.yml index 6c85bd143f1fb4..c2c5ed969f3c24 100644 --- a/.github/workflows/pull_request_stats.yml +++ b/.github/workflows/pull_request_stats.yml @@ -28,14 +28,16 @@ jobs: id: docs-change - name: Cache cargo registry - uses: actions/cache@v1 + uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.docs-change.outputs.DOCS_CHANGE == 'nope' }} with: path: ~/.cargo/registry key: stable-ubuntu-latest-node@14-cargo-registry-trimmed-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v1 + uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.docs-change.outputs.DOCS_CHANGE == 'nope' }} with: path: ~/.cargo/git @@ -48,6 +50,7 @@ jobs: - name: Turbo Cache id: turbo-cache uses: actions/cache@v3 + timeout-minutes: 2 if: ${{ steps.docs-change.outputs.DOCS_CHANGE == 'nope' }} with: path: .turbo @@ -62,6 +65,7 @@ jobs: # So we get latest cache - name: Cache built files uses: actions/cache@v3 + timeout-minutes: 2 with: path: ./packages/next-target key: next-swc-cargo-cache-ubuntu-latest--${{ hashFiles('**/Cargo.lock') }} diff --git a/contributing/core/testing.md b/contributing/core/testing.md index e0b250715b8d57..98ef58cc37a79a 100644 --- a/contributing/core/testing.md +++ b/contributing/core/testing.md @@ -38,8 +38,6 @@ When you run an e2e test, a local version of next will be created inside your sy which is then linked to the app, also created inside a temp folder. A server is started on a random port, against which the tests will run. After all tests have finished, the server is destroyed and all remaining files are deleted from the temp folder. -You will need `yarn` for running e2e tests. Installing it `corepack` won't work because `next.js` is `pnpm` workspace. - ## Writing tests for Next.js ### Getting Started diff --git a/docs/testing.md b/docs/testing.md index 7414761d37c3b1..9618a335ff6fd8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,7 +86,7 @@ export default function About() { Add a test to check your navigation is working correctly: ```jsx -// cypress/integration/app.spec.js +// cypress/e2e/app.cy.js describe('Navigation', () => { it('should navigate to the about page', () => { diff --git a/errors/invalid-new-link-with-extra-anchor.md b/errors/invalid-new-link-with-extra-anchor.md index c91b53f7acf664..58122c6097f30b 100644 --- a/errors/invalid-new-link-with-extra-anchor.md +++ b/errors/invalid-new-link-with-extra-anchor.md @@ -14,7 +14,7 @@ npx @next/codemod new-link . This will change `Home` to `Home`. -Alternatively, you can add the `legacyBehavior` prop `Home`. +Alternatively, you can add the `legacyBehavior` prop `Home`. ### Useful Links diff --git a/examples/convex/package.json b/examples/convex/package.json index 44d70ad5bd4b81..b3206736c7bb56 100644 --- a/examples/convex/package.json +++ b/examples/convex/package.json @@ -10,7 +10,7 @@ "start": "next start" }, "dependencies": { - "convex": "latest", + "convex": "0.6.0", "next": "latest", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/examples/with-mqtt-js/README.md b/examples/with-mqtt-js/README.md index 0e93517096b51f..2c567450007d7f 100644 --- a/examples/with-mqtt-js/README.md +++ b/examples/with-mqtt-js/README.md @@ -14,8 +14,14 @@ Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packag ```bash npx create-next-app --example with-mqtt-js with-mqtt-js-app -# or -yarn create-next-app --example with-mqtt-js with-mqtt-js-app +``` + +```bash +yarn create next-app --example with-mqtt-js with-mqtt-js-app +``` + +```bash +pnpm create next-app --example with-mqtt-js with-mqtt-js-app ``` To set up a connection URI with a mqtt client, copy the `.env.local.example` file in this directory to `.env.local` (which will be ignored by Git): diff --git a/jest.config.js b/jest.config.js index 177949779f0506..98323edc981d8a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ const customJestConfig = { setupFilesAfterEnv: ['/jest-setup-after-env.ts'], verbose: true, rootDir: 'test', + roots: ['', '/../packages/next/src/'], modulePaths: ['/lib'], transformIgnorePatterns: ['/next[/\\\\]dist/', '/\\.next/'], globals: { diff --git a/lerna.json b/lerna.json index 338c40210a2dfe..9c90cd022930e5 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "13.1.5-canary.2" + "version": "13.1.6-canary.0" } diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index 76839d335137a0..f6bc029ebd6e28 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -11,9 +11,26 @@ import { getPkgManager } from './helpers/get-pkg-manager' import { validateNpmName } from './helpers/validate-pkg' import packageJson from './package.json' import ciInfo from 'ci-info' +import { isFolderEmpty } from './helpers/is-folder-empty' +import fs from 'fs' let projectPath: string = '' +const handleSigTerm = () => process.exit(0) + +process.on('SIGINT', handleSigTerm) +process.on('SIGTERM', handleSigTerm) + +const onPromptState = (state: any) => { + if (state.aborted) { + // If we don't re-enable the terminal cursor before exiting + // the program, the cursor will remain hidden + process.stdout.write('\x1B[?25h') + process.stdout.write('\n') + process.exit(1) + } +} + const program = new Commander.Command(packageJson.name) .version(packageJson.version) .arguments('') @@ -127,6 +144,7 @@ async function run(): Promise { if (!projectPath) { const res = await prompts({ + onState: onPromptState, type: 'text', name: 'path', message: 'What is your project named?', @@ -180,6 +198,17 @@ async function run(): Promise { process.exit(1) } + /** + * Verify the project dir is empty or doesn't exist + */ + const root = path.resolve(resolvedProjectPath) + const appName = path.basename(root) + const folderExists = fs.existsSync(root) + + if (folderExists && !isFolderEmpty(root, appName)) { + process.exit(1) + } + const example = typeof program.example === 'string' && program.example.trim() const preferences = (conf.get('preferences') || {}) as Record< string, @@ -248,6 +277,7 @@ async function run(): Promise { } else { const styledEslint = chalk.hex('#007acc')('ESLint') const { eslint } = await prompts({ + onState: onPromptState, type: 'toggle', name: 'eslint', message: `Would you like to use ${styledEslint} with this project?`, @@ -269,6 +299,7 @@ async function run(): Promise { } else { const styledSrcDir = chalk.hex('#007acc')('`src/` directory') const { srcDir } = await prompts({ + onState: onPromptState, type: 'toggle', name: 'srcDir', message: `Would you like to use ${styledSrcDir} with this project?`, @@ -292,6 +323,7 @@ async function run(): Promise { 'experimental `app/` directory' ) const { appDir } = await prompts({ + onState: onPromptState, type: 'toggle', name: 'appDir', message: `Would you like to use ${styledAppDir} with this project?`, @@ -310,22 +342,29 @@ async function run(): Promise { if (ciInfo.isCI) { program.importAlias = '@/*' } else { - const styledImportAlias = chalk.hex('#007acc')('import alias') - const { importAlias } = await prompts({ - type: 'text', - name: 'importAlias', - message: `What ${styledImportAlias} would you like configured?`, - initial: getPrefOrDefault('importAlias'), - }) - - if (!/.+\/\*/.test(importAlias)) { - console.error( - `${chalk.red( - 'Error:' - )} invalid import alias (${importAlias}), it must follow the pattern /*` - ) - process.exit(1) + let importAlias = '' + + const promptAlias = async () => { + const styledImportAlias = chalk.hex('#007acc')('import alias') + const promptResult = await prompts({ + onState: onPromptState, + type: 'text', + name: 'importAlias', + message: `What ${styledImportAlias} would you like configured?`, + initial: getPrefOrDefault('importAlias'), + }) + importAlias = promptResult.importAlias + + if (!/.+\/\*/.test(importAlias)) { + console.error( + `${chalk.red( + 'Error:' + )} invalid import alias (${importAlias}), it must follow the pattern /*` + ) + await promptAlias() + } } + await promptAlias() program.importAlias = importAlias preferences.importAlias = importAlias @@ -351,6 +390,7 @@ async function run(): Promise { } const res = await prompts({ + onState: onPromptState, type: 'confirm', name: 'builtin', message: diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index fe9b906a289417..5c5f2dfc875700 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.1.5-canary.2", + "version": "13.1.6-canary.0", "keywords": [ "react", "next", diff --git a/packages/create-next-app/templates/app/js/README-template.md b/packages/create-next-app/templates/app/js/README-template.md index 279bcab7817fa8..d95eb3fe5dcd41 100644 --- a/packages/create-next-app/templates/app/js/README-template.md +++ b/packages/create-next-app/templates/app/js/README-template.md @@ -14,7 +14,7 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.jsx`. The page auto-updates as you edit the file. +You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. diff --git a/packages/create-next-app/templates/app/js/app/head.jsx b/packages/create-next-app/templates/app/js/app/head.js similarity index 100% rename from packages/create-next-app/templates/app/js/app/head.jsx rename to packages/create-next-app/templates/app/js/app/head.js diff --git a/packages/create-next-app/templates/app/js/app/layout.jsx b/packages/create-next-app/templates/app/js/app/layout.js similarity index 72% rename from packages/create-next-app/templates/app/js/app/layout.jsx rename to packages/create-next-app/templates/app/js/app/layout.js index e6aab9c27dc0c2..fca8f5b423eb65 100644 --- a/packages/create-next-app/templates/app/js/app/layout.jsx +++ b/packages/create-next-app/templates/app/js/app/layout.js @@ -5,7 +5,7 @@ export default function RootLayout({ children }) { {/* will contain the components returned by the nearest parent - head.jsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head + head.js. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head */} {children} diff --git a/packages/create-next-app/templates/app/js/app/page.jsx b/packages/create-next-app/templates/app/js/app/page.js similarity index 97% rename from packages/create-next-app/templates/app/js/app/page.jsx rename to packages/create-next-app/templates/app/js/app/page.js index ca2f14fe4230be..a8594adb524912 100644 --- a/packages/create-next-app/templates/app/js/app/page.jsx +++ b/packages/create-next-app/templates/app/js/app/page.js @@ -10,7 +10,7 @@ export default function Home() {

Get started by editing  - app/page.jsx + app/page.js

, + #[serde(default)] + pub app_dir: Option, + #[serde(default)] pub is_page_file: bool, @@ -162,6 +165,7 @@ where file.name.clone(), config.clone(), comments.clone(), + opts.app_dir.clone() )), _ => Either::Right(noop()), }, diff --git a/packages/next-swc/crates/core/src/react_server_components.rs b/packages/next-swc/crates/core/src/react_server_components.rs index ee670930165fa1..a32d81b7cf98fa 100644 --- a/packages/next-swc/crates/core/src/react_server_components.rs +++ b/packages/next-swc/crates/core/src/react_server_components.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use regex::Regex; use serde::Deserialize; @@ -5,7 +7,7 @@ use next_binding::swc::core::{ common::{ comments::{Comment, CommentKind, Comments}, errors::HANDLER, - FileName, Span, DUMMY_SP, + FileName, Span, Spanned, DUMMY_SP, }, ecma::ast::*, ecma::atoms::{js_word, JsWord}, @@ -38,6 +40,7 @@ pub struct Options { struct ReactServerComponents { is_server: bool, filepath: String, + app_dir: Option, comments: C, invalid_server_imports: Vec, invalid_client_imports: Vec, @@ -290,6 +293,32 @@ impl ReactServerComponents { } self.assert_invalid_api(module); + self.assert_server_filename(module); + } + + fn assert_server_filename(&self, module: &Module) { + let is_error_file = Regex::new(r"/error\.(ts|js)x?$") + .unwrap() + .is_match(&self.filepath); + if is_error_file { + if let Some(app_dir) = &self.app_dir { + if let Some(app_dir) = app_dir.to_str() { + if self.filepath.starts_with(app_dir) { + HANDLER.with(|handler| { + let span = if let Some(first_item) = module.body.first() { + first_item.span() + } else { + module.span + }; + + handler + .struct_span_err(span, "NEXT_RSC_ERR_ERROR_FILE_SERVER_COMPONENT") + .emit() + }) + } + } + } + } } fn assert_client_graph(&self, imports: &Vec, module: &Module) { @@ -416,6 +445,7 @@ pub fn server_components( filename: FileName, config: Config, comments: C, + app_dir: Option, ) -> impl Fold + VisitMut { let is_server: bool = match config { Config::WithOptions(x) => x.is_server, @@ -425,6 +455,7 @@ pub fn server_components( is_server, comments, filepath: filename.to_string(), + app_dir, invalid_server_imports: vec![ JsWord::from("client-only"), JsWord::from("react-dom/client"), diff --git a/packages/next-swc/crates/core/tests/errors.rs b/packages/next-swc/crates/core/tests/errors.rs index 2d55603c9c3754..ef06d6b020b33c 100644 --- a/packages/next-swc/crates/core/tests/errors.rs +++ b/packages/next-swc/crates/core/tests/errors.rs @@ -89,6 +89,7 @@ fn react_server_components_server_graph_errors(input: PathBuf) { next_swc::react_server_components::Options { is_server: true }, ), tr.comments.as_ref().clone(), + None, ) }, &input, @@ -112,6 +113,7 @@ fn react_server_components_client_graph_errors(input: PathBuf) { next_swc::react_server_components::Options { is_server: false }, ), tr.comments.as_ref().clone(), + None, ) }, &input, diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index ec1351cd075622..e231096050a419 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -252,6 +252,7 @@ fn react_server_components_server_graph_fixture(input: PathBuf) { next_swc::react_server_components::Options { is_server: true }, ), tr.comments.as_ref().clone(), + None, ) }, &input, @@ -272,6 +273,7 @@ fn react_server_components_client_graph_fixture(input: PathBuf) { next_swc::react_server_components::Options { is_server: false }, ), tr.comments.as_ref().clone(), + None, ) }, &input, diff --git a/packages/next-swc/crates/core/tests/full.rs b/packages/next-swc/crates/core/tests/full.rs index 9bde0c87f03f9b..9a3848157ca496 100644 --- a/packages/next-swc/crates/core/tests/full.rs +++ b/packages/next-swc/crates/core/tests/full.rs @@ -70,6 +70,7 @@ fn test(input: &Path, minify: bool) { emotion: Some(assert_json("{}")), modularize_imports: None, font_loaders: None, + app_dir: None, server_actions: None, }; diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 74d02b3cbd347f..3c79320651b510 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "13.1.5-canary.2", + "version": "13.1.6-canary.0", "private": true, "scripts": { "build-native": "napi build --platform -p next-swc-napi --cargo-name next_swc_napi --features plugin,rustls-tls --js false native", diff --git a/packages/next/package.json b/packages/next/package.json index f2057ea7edd066..cacc4879173cb9 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "13.1.5-canary.2", + "version": "13.1.6-canary.0", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -77,7 +77,7 @@ ] }, "dependencies": { - "@next/env": "13.1.5-canary.2", + "@next/env": "13.1.6-canary.0", "@swc/helpers": "0.4.14", "caniuse-lite": "^1.0.30001406", "postcss": "8.4.14", @@ -127,11 +127,11 @@ "@hapi/accept": "5.0.2", "@napi-rs/cli": "2.13.3", "@napi-rs/triples": "1.1.0", - "@next/polyfill-module": "13.1.5-canary.2", - "@next/polyfill-nomodule": "13.1.5-canary.2", - "@next/react-dev-overlay": "13.1.5-canary.2", - "@next/react-refresh-utils": "13.1.5-canary.2", - "@next/swc": "13.1.5-canary.2", + "@next/polyfill-module": "13.1.6-canary.0", + "@next/polyfill-nomodule": "13.1.6-canary.0", + "@next/react-dev-overlay": "13.1.6-canary.0", + "@next/react-refresh-utils": "13.1.6-canary.0", + "@next/swc": "13.1.6-canary.0", "@segment/ajv-human-errors": "2.1.2", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", diff --git a/packages/next/src/build/babel/plugins/react-loadable-plugin.ts b/packages/next/src/build/babel/plugins/react-loadable-plugin.ts index 4169ae4e4de060..045f29271a673a 100644 --- a/packages/next/src/build/babel/plugins/react-loadable-plugin.ts +++ b/packages/next/src/build/babel/plugins/react-loadable-plugin.ts @@ -114,6 +114,7 @@ export default function ({ | BabelTypes.ObjectProperty | BabelTypes.ObjectMethod | BabelTypes.SpreadElement + | BabelTypes.BooleanLiteral > } = {} @@ -140,6 +141,23 @@ export default function ({ const dynamicImports: BabelTypes.Expression[] = [] const dynamicKeys: BabelTypes.Expression[] = [] + if (propertiesMap.ssr) { + const ssr = propertiesMap.ssr.get('value') + const nodePath = Array.isArray(ssr) ? undefined : ssr + + if (nodePath) { + const nonSSR = + nodePath.node.type === 'BooleanLiteral' && + nodePath.node.value === false + // If `ssr` is set to `false`, erase the loader for server side + if (nonSSR && loader && state.file.opts.caller?.isServer) { + loader.replaceWith( + t.arrowFunctionExpression([], t.nullLiteral(), true) + ) + } + } + } + loader.traverse({ Import(importPath) { const importArguments = importPath.parentPath.get('arguments') diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index d9d27ca2fe74d9..9fa7078aba25c2 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2210,6 +2210,7 @@ export default async function build( const exportOptions = { silent: false, buildExport: true, + debugOutput, threads: config.experimental.cpus, pages: combinedPages, outdir: path.join(distDir, 'export'), @@ -2358,7 +2359,9 @@ export default async function build( for (const [originalAppPath, routes] of appStaticPaths) { const page = appNormalizedPaths.get(originalAppPath) || '' const appConfig = appDefaultConfigs.get(originalAppPath) || {} - let hasDynamicData = appConfig.revalidate === 0 + let hasDynamicData = + appConfig.revalidate === 0 || + exportConfig.initialPageRevalidationMap[page] === 0 routes.forEach((route) => { if (isDynamicRoute(page) && route === page) return diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index 8a49d15d4dcc43..a8edfb7bae0ea6 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -219,6 +219,7 @@ export function getLoaderSWCOptions({ development, isServer, pagesDir, + appDir, isPageFile, hasReactRefresh, nextConfig, @@ -265,6 +266,7 @@ any) { isDevelopment: development, isServer, pagesDir, + appDir, isPageFile, env: { targets: { @@ -290,6 +292,7 @@ any) { isDevelopment: development, isServer, pagesDir, + appDir, isPageFile, ...(supportedBrowsers && supportedBrowsers.length > 0 ? { diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index e10a1e1e69dc8d..1943c5a79f4102 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -713,6 +713,7 @@ export default async function getBaseWebpackConfig( isServer: isNodeServer || isEdgeServer, rootDir: dir, pagesDir, + appDir, hasServerComponents, hasReactRefresh: dev && isClient, fileReading: config.experimental.swcFileReading, diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index b6ee93ff9ec23e..82a357af7badef 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -7,6 +7,7 @@ import { sep } from 'path' import { verifyRootLayout } from '../../../lib/verifyRootLayout' import * as Log from '../../../build/output/log' import { APP_DIR_ALIAS } from '../../../lib/constants' +import { resolveFileBasedMetadataForLoader } from '../../../lib/metadata/resolve-metadata' const FILE_TYPES = { layout: 'layout', @@ -36,7 +37,10 @@ async function createTreeCodeFromPath({ resolveParallelSegments, }: { pagePath: string - resolve: (pathname: string) => Promise + resolve: ( + pathname: string, + resolveDir?: boolean + ) => Promise resolveParallelSegments: ( pathname: string ) => [key: string, segment: string][] @@ -44,6 +48,7 @@ async function createTreeCodeFromPath({ const splittedPath = pagePath.split(/[\\/]/) const appDirPrefix = splittedPath[0] const pages: string[] = [] + let rootLayout: string | undefined let globalError: string | undefined @@ -51,6 +56,7 @@ async function createTreeCodeFromPath({ segments: string[] ): Promise<{ treeCode: string + treeMetadataCode: string }> { const segmentPath = segments.join('/') @@ -65,12 +71,26 @@ async function createTreeCodeFromPath({ parallelSegments.push(...resolveParallelSegments(segmentPath)) } + let metadataCode = '' + for (const [parallelKey, parallelSegment] of parallelSegments) { if (parallelSegment === PAGE_SEGMENT) { const matchedPagePath = `${appDirPrefix}${segmentPath}/page` const resolvedPagePath = await resolve(matchedPagePath) if (resolvedPagePath) pages.push(resolvedPagePath) + metadataCode += `{ + type: 'page', + layer: ${ + // There's an extra virtual segment. + segments.length - 1 + }, + mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify( + resolvedPagePath + )}), + path: ${JSON.stringify(resolvedPagePath)}, + },` + // Use '' for segment as it's the page. There can't be a segment called '' so this is the safest way to add it. props[parallelKey] = `['', {}, { page: [() => import(/* webpackMode: "eager" */ ${JSON.stringify( @@ -80,9 +100,8 @@ async function createTreeCodeFromPath({ } const parallelSegmentPath = segmentPath + '/' + parallelSegment - const { treeCode: subtreeCode } = await createSubtreePropsFromSegmentPath( - [...segments, parallelSegment] - ) + const { treeCode: subtreeCode, treeMetadataCode: subTreeMetadataCode } = + await createSubtreePropsFromSegmentPath([...segments, parallelSegment]) // `page` is not included here as it's added above. const filePaths = await Promise.all( @@ -101,6 +120,27 @@ async function createTreeCodeFromPath({ rootLayout = layoutPath } + // Collect metadata for the layout + if (layoutPath) { + metadataCode += `{ + type: 'layout', + layer: ${segments.length}, + mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify( + layoutPath + )}), + path: ${JSON.stringify(layoutPath)}, + },` + } + metadataCode += await resolveFileBasedMetadataForLoader( + segments.length, + (await resolve(`${appDirPrefix}${parallelSegmentPath}/`, true))! + ) + metadataCode += subTreeMetadataCode + + if (!rootLayout) { + rootLayout = layoutPath + } + if (!globalError) { globalError = await resolve( `${appDirPrefix}${parallelSegmentPath}/${GLOBAL_ERROR_FILE_TYPE}` @@ -133,13 +173,16 @@ async function createTreeCodeFromPath({ .map(([key, value]) => `${key}: ${value}`) .join(',\n')} }`, + treeMetadataCode: metadataCode, } } - const { treeCode } = await createSubtreePropsFromSegmentPath([]) + const { treeCode, treeMetadataCode } = + await createSubtreePropsFromSegmentPath([]) return { treeCode: `const tree = ${treeCode}.children;`, - pages, + treeMetadataCode: `const metadata = [${treeMetadataCode}];`, + pages: `const pages = ${JSON.stringify(pages)};`, rootLayout, globalError, } @@ -197,7 +240,7 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ const rest = path.slice(pathname.length + 1).split('/') let matchedSegment = rest[0] - // It is the actual page, mark it sepcially. + // It is the actual page, mark it specially. if (rest.length === 1 && matchedSegment === 'page') { matchedSegment = PAGE_SEGMENT } @@ -212,7 +255,11 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ return Object.entries(matched) } - const resolver = async (pathname: string) => { + const resolver = async (pathname: string, resolveDir?: boolean) => { + if (resolveDir) { + return createAbsolutePath(appDir, pathname) + } + try { const resolved = await resolve(this.rootContext, pathname) this.addDependency(resolved) @@ -230,12 +277,17 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ } } - const { treeCode, pages, rootLayout, globalError } = - await createTreeCodeFromPath({ - pagePath, - resolve: resolver, - resolveParallelSegments, - }) + const { + treeCode, + treeMetadataCode, + pages: pageListCode, + rootLayout, + globalError, + } = await createTreeCodeFromPath({ + pagePath, + resolve: resolver, + resolveParallelSegments, + }) if (!rootLayout) { const errorMessage = `${chalk.bold( @@ -263,7 +315,8 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ const result = ` export ${treeCode} - export const pages = ${JSON.stringify(pages)} + export ${treeMetadataCode} + export ${pageListCode} export { default as AppRouter } from 'next/dist/client/components/app-router' export { default as LayoutRouter } from 'next/dist/client/components/layout-router' diff --git a/packages/next/src/build/webpack/loaders/next-swc-loader.ts b/packages/next/src/build/webpack/loaders/next-swc-loader.ts index f8c9af32a0ae56..a12b49a6f4f9e0 100644 --- a/packages/next/src/build/webpack/loaders/next-swc-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-swc-loader.ts @@ -45,6 +45,7 @@ async function loaderTransform( isServer, rootDir, pagesDir, + appDir, hasReactRefresh, nextConfig, jsConfig, @@ -58,6 +59,7 @@ async function loaderTransform( const swcOptions = getLoaderSWCOptions({ pagesDir, + appDir, filename, isServer, isPageFile, diff --git a/packages/next/src/build/webpack/plugins/flight-types-plugin.ts b/packages/next/src/build/webpack/plugins/flight-types-plugin.ts index 3f41c5818adf1d..783b3843cb4425 100644 --- a/packages/next/src/build/webpack/plugins/flight-types-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-types-plugin.ts @@ -64,6 +64,7 @@ interface IEntry { ? "runtime?: 'nodejs' | 'experimental-edge' | 'edge'" : '' } + metadata?: any } // ============= diff --git a/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts b/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts index 16f272cfb83cf4..652fa6af26e34e 100644 --- a/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts +++ b/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts @@ -5,7 +5,8 @@ import { SimpleWebpackError } from './simpleWebpackError' function formatRSCErrorMessage( message: string, - isPagesDir: boolean + isPagesDir: boolean, + fileName: string ): [string, string] { let formattedMessage = message let formattedVerboseMessage = '' @@ -19,6 +20,8 @@ function formatRSCErrorMessage( const NEXT_RSC_ERR_CLIENT_DIRECTIVE_PAREN = /.+NEXT_RSC_ERR_CLIENT_DIRECTIVE_PAREN\n/s const NEXT_RSC_ERR_INVALID_API = /.+NEXT_RSC_ERR_INVALID_API: (.*?)\n/s + const NEXT_RSC_ERR_ERROR_FILE_SERVER_COMPONENT = + /.+NEXT_RSC_ERR_ERROR_FILE_SERVER_COMPONENT/ if (NEXT_RSC_ERR_REACT_API.test(message)) { const matches = message.match(NEXT_RSC_ERR_REACT_API) @@ -100,6 +103,12 @@ function formatRSCErrorMessage( `\n\n"$1" is not supported in app/. Read more: https://beta.nextjs.org/docs/data-fetching/fundamentals\n\n` ) formattedVerboseMessage = '\n\nFile path:\n' + } else if (NEXT_RSC_ERR_ERROR_FILE_SERVER_COMPONENT.test(message)) { + formattedMessage = message.replace( + NEXT_RSC_ERR_ERROR_FILE_SERVER_COMPONENT, + `\n\n${fileName} must be a Client Component. Add the "use client" directive the top of the file to resolve this issue.\n\n` + ) + formattedVerboseMessage = '\n\nImport path:\n' } return [formattedMessage, formattedVerboseMessage] @@ -137,7 +146,11 @@ export function getRscError( current = origin } - const formattedError = formatRSCErrorMessage(err.message, isPagesDir) + const formattedError = formatRSCErrorMessage( + err.message, + isPagesDir, + fileName + ) const error = new SimpleWebpackError( fileName, diff --git a/packages/next/src/cli/next-build.ts b/packages/next/src/cli/next-build.ts index 9232555a5a6e2a..da2eb113863273 100755 --- a/packages/next/src/cli/next-build.ts +++ b/packages/next/src/cli/next-build.ts @@ -72,7 +72,7 @@ const nextBuild: CliCommand = (argv) => { dir, null, args['--profile'], - args['--debug'], + args['--debug'] || process.env.NEXT_DEBUG_BUILD, !args['--no-lint'], args['--no-mangling'] ).catch((err) => { diff --git a/packages/next/src/client/components/head.tsx b/packages/next/src/client/components/head.tsx deleted file mode 100644 index 0b123102d6c7b6..00000000000000 --- a/packages/next/src/client/components/head.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -export function DefaultHead() { - return ( - <> - - - - ) -} diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 2ef524f81a6fc4..a02f9d3543a26e 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -24,6 +24,7 @@ import { createInfinitePromise } from './infinite-promise' import { ErrorBoundary } from './error-boundary' import { matchSegment } from './match-segments' import { useRouter } from './navigation' +import { handleSmoothScroll } from '../../shared/lib/router/router' /** * Add refetch marker to router state at the point of the current layout segment. @@ -103,11 +104,11 @@ function findDOMNode( } /** - * Check if the top of the HTMLElement is in the viewport. + * Check if the top corner of the HTMLElement is in the viewport. */ -function topOfElementInViewport(element: HTMLElement) { +function topOfElementInViewport(element: HTMLElement, viewportHeight: number) { const rect = element.getBoundingClientRect() - return rect.top >= 0 + return rect.top >= 0 && rect.top <= viewportHeight } class ScrollAndFocusHandler extends React.Component<{ @@ -122,20 +123,39 @@ class ScrollAndFocusHandler extends React.Component<{ if (focusAndScrollRef.apply && domNode instanceof HTMLElement) { // State is mutated to ensure that the focus and scroll is applied only once. focusAndScrollRef.apply = false + + handleSmoothScroll( + () => { + // Store the current viewport height because reading `clientHeight` causes a reflow, + // and it won't change during this function. + const htmlElement = document.documentElement + const viewportHeight = htmlElement.clientHeight + + // If the element's top edge is already in the viewport, exit early. + if (topOfElementInViewport(domNode, viewportHeight)) { + return + } + + // Otherwise, try scrolling go the top of the document to be backward compatible with pages + // scrollIntoView() called on `` element scrolls horizontally on chrome and firefox (that shouldn't happen) + // We could use it to scroll horizontally following RTL but that also seems to be broken - it will always scroll left + // scrollLeft = 0 also seems to ignore RTL and manually checking for RTL is too much hassle so we will scroll just vertically + htmlElement.scrollTop = 0 + + // Scroll to domNode if domNode is not in viewport when scrolled to top of document + if (!topOfElementInViewport(domNode, viewportHeight)) { + // Scroll into view doesn't scroll horizontally by default when not needed + domNode.scrollIntoView() + } + }, + { + // We will force layout by querying domNode position + dontForceLayout: true, + } + ) + // Set focus on the element domNode.focus() - // Only scroll into viewport when the layout is not visible currently. - if (!topOfElementInViewport(domNode)) { - const htmlElement = document.documentElement - const existing = htmlElement.style.scrollBehavior - htmlElement.style.scrollBehavior = 'auto' - // In Chrome-based browsers we need to force reflow before calling `scrollTo`. - // Otherwise it will not pickup the change in scrollBehavior - // More info here: https://github.com/vercel/next.js/issues/40719#issuecomment-1336248042 - htmlElement.getClientRects() - domNode.scrollIntoView() - htmlElement.style.scrollBehavior = existing - } } } diff --git a/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx index 2decd9b7b58430..976e1ed095ace5 100644 --- a/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx @@ -30,6 +30,7 @@ import { useWebsocket, useWebsocketPing, } from './internal/helpers/use-websocket' +import { parseComponentStack } from './internal/helpers/parse-component-stack' interface Dispatcher { onBuildOk(): void @@ -421,10 +422,14 @@ export default function HotReload({ }, [dispatch]) const handleOnUnhandledError = useCallback((error: Error): void => { + // Component stack is added to the error in use-error-handler + const componentStack = (error as any)._componentStack dispatch({ type: ACTION_UNHANDLED_ERROR, reason: error, frames: parseStack(error.stack!), + componentStackFrames: + componentStack && parseComponentStack(componentStack), }) }, []) const handleOnUnhandledRejection = useCallback((reason: Error): void => { diff --git a/packages/next/src/client/components/react-dev-overlay/internal/components/CodeFrame/CodeFrame.tsx b/packages/next/src/client/components/react-dev-overlay/internal/components/CodeFrame/CodeFrame.tsx index ba548f3965ab50..eb8b8172b28809 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/components/CodeFrame/CodeFrame.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/components/CodeFrame/CodeFrame.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { StackFrame } from 'next/dist/compiled/stacktrace-parser' import stripAnsi from 'next/dist/compiled/strip-ansi' import { getFrameSource } from '../../helpers/stack-frame' +import { useOpenInEditor } from '../../helpers/use-open-in-editor' export type CodeFrameProps = { stackFrame: StackFrame; codeFrame: string } @@ -44,25 +45,11 @@ export const CodeFrame: React.FC = function CodeFrame({ }) }, [formattedFrame]) - const open = React.useCallback(() => { - const params = new URLSearchParams() - for (const key in stackFrame) { - params.append(key, ((stackFrame as any)[key] ?? '').toString()) - } - - self - .fetch( - `${ - process.env.__NEXT_ROUTER_BASEPATH || '' - }/__nextjs_launch-editor?${params.toString()}` - ) - .then( - () => {}, - () => { - console.error('There was an issue opening this code in your editor.') - } - ) - }, [stackFrame]) + const open = useOpenInEditor({ + file: stackFrame.file, + lineNumber: stackFrame.lineNumber, + column: stackFrame.column, + }) // TODO: make the caret absolute return ( diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx index 96c79102f2e6c3..8b23cf6c80412b 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx @@ -4,66 +4,53 @@ import { getFrameSource, type OriginalStackFrame, } from '../../helpers/stack-frame' +import { useOpenInEditor } from '../../helpers/use-open-in-editor' -export const CallStackFrame: React.FC<{ frame: OriginalStackFrame }> = - function CallStackFrame({ frame }) { - // TODO: ability to expand resolved frames - // TODO: render error or external indicator +export const CallStackFrame: React.FC<{ + frame: OriginalStackFrame +}> = function CallStackFrame({ frame }) { + // TODO: ability to expand resolved frames + // TODO: render error or external indicator - const f: StackFrame = frame.originalStackFrame ?? frame.sourceStackFrame - const hasSource = Boolean(frame.originalCodeFrame) + const f: StackFrame = frame.originalStackFrame ?? frame.sourceStackFrame + const hasSource = Boolean(frame.originalCodeFrame) + const open = useOpenInEditor( + hasSource + ? { + file: f.file, + lineNumber: f.lineNumber, + column: f.column, + } + : undefined + ) - const open = React.useCallback(() => { - if (!hasSource) return - - const params = new URLSearchParams() - for (const key in f) { - params.append(key, ((f as any)[key] ?? '').toString()) - } - - self - .fetch( - `${ - process.env.__NEXT_ROUTER_BASEPATH || '' - }/__nextjs_launch-editor?${params.toString()}` - ) - .then( - () => {}, - () => { - console.error( - 'There was an issue opening this code in your editor.' - ) - } - ) - }, [hasSource, f]) - - return ( -
-
- {f.methodName} -
-
+
+ {f.methodName} +
+
+ {getFrameSource(f)} + - {getFrameSource(f)} - - - - - -
+ + + +
- ) - } +
+ ) +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx new file mode 100644 index 00000000000000..083e274f1ad39b --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import type { ComponentStackFrame } from '../../helpers/parse-component-stack' +import { useOpenInEditor } from '../../helpers/use-open-in-editor' + +export function ComponentStackFrameRow({ + componentStackFrame: { component, file, lineNumber, column }, +}: { + componentStackFrame: ComponentStackFrame +}) { + const open = useOpenInEditor({ + file, + column, + lineNumber, + }) + + return ( +
+
{component}
+ {file ? ( +
+ + {file} ({lineNumber}:{column}) + + + + + + +
+ ) : null} +
+ ) +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx index 5cf93b575233c5..bc62d5bb59bf67 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx @@ -6,6 +6,7 @@ import { OriginalStackFrame } from '../../helpers/stack-frame' import { groupStackFramesByFramework } from '../../helpers/group-stack-frames-by-framework' import { CallStackFrame } from './CallStackFrame' import { GroupedStackFrames } from './GroupedStackFrames' +import { ComponentStackFrameRow } from './ComponentStackFrameRow' export type RuntimeErrorProps = { error: ReadyRuntimeError } @@ -84,6 +85,19 @@ const RuntimeError: React.FC = function RuntimeError({ /> ) : undefined} + + {error.componentStackFrames ? ( + <> +
Component Stack
+ {error.componentStackFrames.map((componentStackFrame, index) => ( + + ))} + + ) : null} + {stackFramesGroupedByFramework.length ? (
Call Stack
@@ -119,11 +133,13 @@ export const styles = css` color: var(--color-accents-3); } - [data-nextjs-call-stack-frame]:not(:last-child) { + [data-nextjs-call-stack-frame]:not(:last-child), + [data-nextjs-component-stack-frame]:not(:last-child) { margin-bottom: var(--size-gap-double); } - [data-nextjs-call-stack-frame] > h6 { + [data-nextjs-call-stack-frame] > h6, + [data-nextjs-component-stack-frame] > h6 { margin-top: 0; margin-bottom: var(--size-gap); font-family: var(--font-stack-monospace); @@ -132,14 +148,16 @@ export const styles = css` [data-nextjs-call-stack-frame] > h6[data-nextjs-frame-expanded='false'] { color: #666; } - [data-nextjs-call-stack-frame] > div { + [data-nextjs-call-stack-frame] > div, + [data-nextjs-component-stack-frame] > div { display: flex; align-items: center; padding-left: calc(var(--size-gap) + var(--size-gap-half)); font-size: var(--size-font-small); color: #999; } - [data-nextjs-call-stack-frame] > div > svg { + [data-nextjs-call-stack-frame] > div > svg, + [data-nextjs-component-stack-frame] > div > svg { width: auto; height: var(--size-font-small); margin-left: var(--size-gap); @@ -147,13 +165,16 @@ export const styles = css` display: none; } - [data-nextjs-call-stack-frame] > div[data-has-source] { + [data-nextjs-call-stack-frame] > div[data-has-source], + [data-nextjs-component-stack-frame] > div { cursor: pointer; } - [data-nextjs-call-stack-frame] > div[data-has-source]:hover { + [data-nextjs-call-stack-frame] > div[data-has-source]:hover, + [data-nextjs-component-stack-frame] > div:hover { text-decoration: underline dotted; } - [data-nextjs-call-stack-frame] > div[data-has-source] > svg { + [data-nextjs-call-stack-frame] > div[data-has-source] > svg, + [data-nextjs-component-stack-frame] > div > svg { display: unset; } diff --git a/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts b/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts index 0553640734b6a7..2bc46a794ff7d6 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts @@ -1,5 +1,6 @@ import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' import { SupportedErrorEvent } from './container/Errors' +import { ComponentStackFrame } from './helpers/parse-component-stack' export const ACTION_BUILD_OK = 'build-ok' export const ACTION_BUILD_ERROR = 'build-error' @@ -25,6 +26,7 @@ export interface UnhandledErrorAction { type: typeof ACTION_UNHANDLED_ERROR reason: Error frames: StackFrame[] + componentStackFrames?: ComponentStackFrame[] } export interface UnhandledRejectionAction { type: typeof ACTION_UNHANDLED_REJECTION diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts index c569f931c3e4ed..9819e90555692c 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts @@ -5,12 +5,14 @@ import { import { SupportedErrorEvent } from '../container/Errors' import { getErrorSource } from './nodeStackFrames' import { getOriginalStackFrames, OriginalStackFrame } from './stack-frame' +import { ComponentStackFrame } from './parse-component-stack' export type ReadyRuntimeError = { id: number runtime: true error: Error frames: OriginalStackFrame[] + componentStackFrames?: ComponentStackFrame[] } export async function getErrorByType( @@ -20,7 +22,7 @@ export async function getErrorByType( switch (event.type) { case ACTION_UNHANDLED_ERROR: case ACTION_UNHANDLED_REJECTION: { - return { + const readyRuntimeError: ReadyRuntimeError = { id, runtime: true, error: event.reason, @@ -30,6 +32,10 @@ export async function getErrorByType( event.reason.toString() ), } + if (event.type === ACTION_UNHANDLED_ERROR) { + readyRuntimeError.componentStackFrames = event.componentStackFrames + } + return readyRuntimeError } default: { break diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts index af2e1c9b66ce67..94e9083b50969f 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts @@ -1,4 +1,5 @@ -export let hydrationErrorInfo: string | undefined +export let hydrationErrorWarning: string | undefined +export let hydrationErrorComponentStack: string | undefined const knownHydrationWarnings = new Set([ 'Warning: Text content did not match. Server: "%s" Client: "%s"%s', @@ -10,18 +11,13 @@ const knownHydrationWarnings = new Set([ export function patchConsoleError() { const prev = console.error - console.error = function ( - msg, - serverContent, - clientContent, - // TODO-APP: Display the component stack in the overlay - _componentStack - ) { + console.error = function (msg, serverContent, clientContent, componentStack) { if (knownHydrationWarnings.has(msg)) { - hydrationErrorInfo = msg + hydrationErrorWarning = msg .replace('%s', serverContent) .replace('%s', clientContent) .replace('%s', '') + hydrationErrorComponentStack = componentStack } // @ts-expect-error argument is defined diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts new file mode 100644 index 00000000000000..b0899877c4ccff --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts @@ -0,0 +1,41 @@ +export type ComponentStackFrame = { + component: string + file?: string + lineNumber?: number + column?: number +} + +export function parseComponentStack( + componentStack: string +): ComponentStackFrame[] { + const componentStackFrames: ComponentStackFrame[] = [] + + for (const line of componentStack.trim().split('\n')) { + // Get component and file from the component stack line + const match = /at ([^ ]+)( \((.*)\))?/.exec(line) + if (match?.[1]) { + const component = match[1] + const webpackFile = match[3] + + // Stop parsing the component stack if we reach a Next.js component + if (webpackFile?.includes('next/dist/client/components/')) { + break + } + + const modulePath = webpackFile?.replace( + /^(webpack-internal:\/\/\/|file:\/\/)(\(.*\)\/)?/, + '' + ) + const [file, lineNumber, column] = modulePath?.split(':') ?? [] + + componentStackFrames.push({ + component, + file, + lineNumber: lineNumber ? Number(lineNumber) : undefined, + column: column ? Number(column) : undefined, + }) + } + } + + return componentStackFrames +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts index 97c6087eb2bfd1..90ea53e8cf547e 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts @@ -1,5 +1,8 @@ import { useEffect } from 'react' -import { hydrationErrorInfo } from './hydration-error-info' +import { + hydrationErrorWarning, + hydrationErrorComponentStack, +} from './hydration-error-info' export type ErrorHandler = (error: Error) => void @@ -61,8 +64,12 @@ if (typeof window !== 'undefined') { 'https://nextjs.org/docs/messages/react-hydration-error' ) ) { - if (hydrationErrorInfo) { - error.message += '\n\n' + hydrationErrorInfo + if (hydrationErrorWarning) { + error.message += '\n\n' + hydrationErrorWarning + } + if (hydrationErrorComponentStack) { + // Component stack added to the error, picked up by the hot-reloader-client + ;(error as any)._componentStack = hydrationErrorComponentStack } error.message += '\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error' diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-open-in-editor.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-open-in-editor.ts new file mode 100644 index 00000000000000..8e2e9e43f4b958 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-open-in-editor.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react' + +export function useOpenInEditor({ + file, + lineNumber, + column, +}: { + file?: string | null + lineNumber?: number | null + column?: number | null +} = {}) { + const openInEditor = useCallback(() => { + if (file == null || lineNumber == null || column == null) return + + const params = new URLSearchParams() + params.append('file', file) + params.append('lineNumber', String(lineNumber)) + params.append('column', String(column)) + + self + .fetch( + `${ + process.env.__NEXT_ROUTER_BASEPATH || '' + }/__nextjs_launch-editor?${params.toString()}` + ) + .then( + () => {}, + () => { + console.error('There was an issue opening this code in your editor.') + } + ) + }, [file, lineNumber, column]) + + return openInEditor +} diff --git a/packages/next/src/client/components/static-generation-async-storage.ts b/packages/next/src/client/components/static-generation-async-storage.ts index 625fff66803a4d..1e868aa01c1242 100644 --- a/packages/next/src/client/components/static-generation-async-storage.ts +++ b/packages/next/src/client/components/static-generation-async-storage.ts @@ -7,11 +7,13 @@ export interface StaticGenerationStore { readonly incrementalCache?: import('../../server/lib/incremental-cache').IncrementalCache readonly isRevalidate?: boolean - revalidate?: number forceDynamic?: boolean - fetchRevalidate?: boolean | number + revalidate?: boolean | number forceStatic?: boolean pendingRevalidates?: Promise[] + + dynamicUsageDescription?: string + dynamicUsageStack?: string } export type StaticGenerationAsyncStorage = diff --git a/packages/next/src/client/components/static-generation-bailout.ts b/packages/next/src/client/components/static-generation-bailout.ts index 91f452a3f6f8d5..27c2dae71b8a28 100644 --- a/packages/next/src/client/components/static-generation-bailout.ts +++ b/packages/next/src/client/components/static-generation-bailout.ts @@ -9,10 +9,13 @@ export function staticGenerationBailout(reason: string): boolean | never { } if (staticGenerationStore?.isStaticGeneration) { - if (staticGenerationStore) { - staticGenerationStore.fetchRevalidate = 0 - } - throw new DynamicServerError(reason) + staticGenerationStore.revalidate = 0 + const err = new DynamicServerError(reason) + + staticGenerationStore.dynamicUsageDescription = reason + staticGenerationStore.dynamicUsageStack = err.stack + + throw err } return false diff --git a/packages/next/src/client/index.tsx b/packages/next/src/client/index.tsx index 5b71492e1efa1c..af145fa6396f51 100644 --- a/packages/next/src/client/index.tsx +++ b/packages/next/src/client/index.tsx @@ -10,6 +10,7 @@ import { RouterContext } from '../shared/lib/router-context' import { AppComponent, AppProps, + handleSmoothScroll, PrivateRouteInfo, } from '../shared/lib/router/router' import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic' @@ -691,15 +692,10 @@ function doRender(input: RenderRouteInfo): Promise { } if (input.scroll) { - const htmlElement = document.documentElement - const existing = htmlElement.style.scrollBehavior - htmlElement.style.scrollBehavior = 'auto' - // In Chrome-based browsers we need to force reflow before calling `scrollTo`. - // Otherwise it will not pickup the change in scrollBehavior - // More info here: https://github.com/vercel/next.js/issues/40719#issuecomment-1336248042 - htmlElement.getClientRects() - window.scrollTo(input.scroll.x, input.scroll.y) - htmlElement.style.scrollBehavior = existing + const { x, y } = input.scroll + handleSmoothScroll(() => { + window.scrollTo(x, y) + }) } } diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index a785731a7c48e6..6ee0f381f3346a 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -140,6 +140,7 @@ interface ExportOptions { outdir: string silent?: boolean threads?: number + debugOutput?: boolean pages?: string[] buildExport?: boolean statusMessage?: string @@ -630,6 +631,7 @@ export default async function exportApp( serverComponents: hasAppDir, appPaths: options.appPaths || [], enableUndici: nextConfig.experimental.enableUndici, + debugOutput: options.debugOutput, }) for (const validation of result.ampValidations || []) { diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index e99098f073429b..9560d7747541bf 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -74,6 +74,7 @@ interface ExportPageInput { serverComponents?: boolean appPaths: string[] enableUndici: NextConfigComplete['experimental']['enableUndici'] + debugOutput?: boolean } interface ExportPageResults { @@ -124,6 +125,7 @@ export default async function exportPage({ httpAgentOptions, serverComponents, enableUndici, + debugOutput, }: ExportPageInput): Promise { setHttpClientAndAgentOptions({ httpAgentOptions, @@ -367,6 +369,25 @@ export default async function exportPage({ `Page with dynamic = "error" encountered dynamic data method ${path}.` ) } + + const { staticBailoutInfo = {} } = curRenderOpts as any + + if ( + revalidate === 0 && + debugOutput && + staticBailoutInfo?.description + ) { + const bailErr = new Error( + `Static generation failed due to dynamic usage on ${path}, reason: ${staticBailoutInfo.description}` + ) + const stack = staticBailoutInfo.stack + + if (stack) { + bailErr.stack = + bailErr.message + stack.substring(stack.indexOf('\n')) + } + console.warn(bailErr) + } } catch (err: any) { if ( err.digest !== DYNAMIC_ERROR_CODE && diff --git a/packages/next/src/lib/metadata/default-metadata.ts b/packages/next/src/lib/metadata/default-metadata.ts new file mode 100644 index 00000000000000..c160233a2fd317 --- /dev/null +++ b/packages/next/src/lib/metadata/default-metadata.ts @@ -0,0 +1,41 @@ +import type { ResolvedMetadata } from './types/metadata-interface' + +export const createDefaultMetadata = (): ResolvedMetadata => { + return { + viewport: 'width=device-width, initial-scale=1', + + // Other values are all null + metadataBase: null, + title: null, + description: null, + applicationName: null, + authors: null, + generator: null, + keywords: null, + referrer: null, + themeColor: null, + colorScheme: null, + creator: null, + publisher: null, + robots: null, + alternates: { + canonical: null, + languages: {}, + }, + icons: null, + openGraph: null, + twitter: null, + verification: {}, + appleWebApp: null, + formatDetection: null, + itunes: null, + abstract: null, + appLinks: null, + archives: null, + assets: null, + bookmarks: null, + category: null, + classification: null, + other: {}, + } +} diff --git a/packages/next/src/lib/metadata/generate/alternate.tsx b/packages/next/src/lib/metadata/generate/alternate.tsx new file mode 100644 index 00000000000000..4bd4eddd148b82 --- /dev/null +++ b/packages/next/src/lib/metadata/generate/alternate.tsx @@ -0,0 +1,51 @@ +import type { ResolvedMetadata } from '../types/metadata-interface' + +import React from 'react' + +export function ResolvedAlternatesMetadata({ + metadata, +}: { + metadata: ResolvedMetadata +}) { + return ( + <> + {metadata.alternates.canonical ? ( + + ) : null} + {Object.entries(metadata.alternates.languages).map(([locale, url]) => + url ? ( + + ) : null + )} + {metadata.alternates.media + ? Object.entries(metadata.alternates.media).map(([media, url]) => + url ? ( + + ) : null + ) + : null} + {metadata.alternates.types + ? Object.entries(metadata.alternates.types).map(([type, url]) => + url ? ( + + ) : null + ) + : null} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/basic.tsx b/packages/next/src/lib/metadata/generate/basic.tsx new file mode 100644 index 00000000000000..ce8b2f07eaa922 --- /dev/null +++ b/packages/next/src/lib/metadata/generate/basic.tsx @@ -0,0 +1,56 @@ +import type { ResolvedMetadata } from '../types/metadata-interface' + +import React from 'react' +import { Meta } from './utils' + +export function ResolvedBasicMetadata({ + metadata, +}: { + metadata: ResolvedMetadata +}) { + return ( + <> + + {metadata.title !== null ? ( + {metadata.title.absolute} + ) : null} + + + + + + + + + + + + + + {metadata.archives + ? metadata.archives.map((archive) => ( + + )) + : null} + {metadata.assets + ? metadata.assets.map((asset) => ( + + )) + : null} + {metadata.bookmarks + ? metadata.bookmarks.map((bookmark) => ( + + )) + : null} + + + {Object.entries(metadata.other).map(([name, content]) => ( + + ))} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx new file mode 100644 index 00000000000000..45389ce1dfc4ba --- /dev/null +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -0,0 +1,223 @@ +import type { ResolvedMetadata } from '../types/metadata-interface' + +import React from 'react' +import { Meta, MultiMeta } from './utils' + +export function ResolvedOpenGraphMetadata({ + openGraph, +}: { + openGraph: ResolvedMetadata['openGraph'] +}) { + if (!openGraph) { + return null + } + + let typedOpenGraph + if ('type' in openGraph) { + switch (openGraph.type) { + case 'website': + typedOpenGraph = + break + case 'article': + typedOpenGraph = ( + <> + + + + + + + + + ) + break + case 'book': + typedOpenGraph = ( + <> + + + + + + + ) + break + case 'profile': + typedOpenGraph = ( + <> + + + + + + + ) + break + case 'music.song': + typedOpenGraph = ( + <> + + + + + + ) + break + case 'music.album': + typedOpenGraph = ( + <> + + + + + + ) + break + case 'music.playlist': + typedOpenGraph = ( + <> + + + + + ) + break + case 'music.radio_station': + typedOpenGraph = ( + <> + + + + ) + break + case 'video.movie': + typedOpenGraph = ( + <> + + + + + + + + + ) + break + case 'video.episode': + typedOpenGraph = ( + <> + + + + + + + + + + ) + break + case 'video.tv_show': + typedOpenGraph = + break + case 'video.other': + typedOpenGraph = + break + default: + throw new Error('Invalid OpenGraph type: ' + (openGraph as any).type) + } + } + + return ( + <> + + + + + + + + + + + + + + + + {typedOpenGraph} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/utils.tsx b/packages/next/src/lib/metadata/generate/utils.tsx new file mode 100644 index 00000000000000..6253b4da33eaee --- /dev/null +++ b/packages/next/src/lib/metadata/generate/utils.tsx @@ -0,0 +1,82 @@ +import React from 'react' + +export function Meta({ + name, + property, + content, +}: { + name?: string + property?: string + content: string | number | URL | null | undefined +}): React.ReactElement | null { + if (typeof content !== 'undefined' && content !== null) { + return ( + + ) + } + return null +} + +export function MultiMeta({ + propertyPrefix, + namePrefix, + contents, +}: { + propertyPrefix?: string + namePrefix?: string + contents: + | ( + | Record + | string + | URL + | number + )[] + | null + | undefined +}) { + if (typeof contents === 'undefined' || contents === null) { + return null + } + + const keyPrefix = propertyPrefix || namePrefix + return ( + <> + {contents.map((content, index) => { + if ( + typeof content === 'string' || + typeof content === 'number' || + content instanceof URL + ) { + return ( + + ) + } else { + return ( + + {Object.entries(content).map(([k, v]) => { + return typeof v === 'undefined' ? null : ( + + ) + })} + + ) + } + })} + + ) +} diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts new file mode 100644 index 00000000000000..c012969b6905d0 --- /dev/null +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -0,0 +1,224 @@ +import type { + Metadata, + ResolvedMetadata, + ResolvingMetadata, +} from './types/metadata-interface' +import type { Viewport } from './types/extra-types' +import type { ResolvedTwitterMetadata } from './types/twitter-types' +import type { AbsoluteTemplateString } from './types/metadata-types' +import { createDefaultMetadata } from './default-metadata' +import { resolveOpenGraph } from './resolve-opengraph' +import { mergeTitle } from './resolve-title' + +const viewPortKeys = { + width: 'width', + height: 'height', + initialScale: 'initial-scale', + minimumScale: 'minimum-scale', + maximumScale: 'maximum-scale', + viewportFit: 'viewport-fit', +} as const + +type Item = + | { + type: 'layout' | 'page' + // A number that represents which layer or routes that the item is in. Starting from 0. + // Layout and page in the same level will share the same `layer`. + layer: number + mod: () => Promise<{ + metadata?: Metadata + generateMetadata?: ( + props: any, + parent: ResolvingMetadata + ) => Promise + }> + path: string + } + | { + type: 'icon' + // A number that represents which layer the item is in. Starting from 0. + layer: number + mod?: () => Promise<{ + metadata?: Metadata + generateMetadata?: ( + props: any, + parent: ResolvingMetadata + ) => Promise + }> + path?: string + } + +// Merge the source metadata into the resolved target metadata. +function merge( + target: ResolvedMetadata, + source: Metadata, + templateStrings: { + title: string | null + openGraph: string | null + twitter: string | null + } +) { + for (const key_ in source) { + const key = key_ as keyof Metadata + + switch (key) { + case 'other': { + Object.assign(target.other, source.other) + break + } + case 'title': { + if (source.title) { + target.title = source.title as AbsoluteTemplateString + mergeTitle(target, templateStrings.title) + } + break + } + case 'openGraph': { + if (typeof source.openGraph !== 'undefined') { + target.openGraph = resolveOpenGraph(source.openGraph) + if (source.openGraph) { + mergeTitle(target.openGraph, templateStrings.openGraph) + } + } else { + target.openGraph = null + } + break + } + case 'twitter': { + if (source.twitter) { + target.twitter = source.twitter as ResolvedTwitterMetadata + mergeTitle(target.twitter, templateStrings.twitter) + } else { + target.twitter = null + } + break + } + case 'viewport': { + let content: string | null = null + const { viewport } = source + if (typeof viewport === 'string') { + content = viewport + } else if (viewport) { + content = '' + for (const viewportKey_ in viewPortKeys) { + const viewportKey = viewportKey_ as keyof Viewport + if (viewport[viewportKey]) { + if (content) content += ', ' + content += `${viewPortKeys[viewportKey]}=${viewport[viewportKey]}` + } + } + } + target.viewport = content + break + } + default: { + // TODO: Make sure the type is correct. + // @ts-ignore + target[key] = source[key] + break + } + } + } +} + +export async function resolveMetadata(metadataItems: Item[]) { + const resolvedMetadata = createDefaultMetadata() + + let committedTitleTemplate: string | null = null + let committedOpenGraphTitleTemplate: string | null = null + let committedTwitterTitleTemplate: string | null = null + + let lastLayer = 0 + // from root layout to page metadata + for (let i = 0; i < metadataItems.length; i++) { + const item = metadataItems[i] + const isLayout = item.type === 'layout' + const isPage = item.type === 'page' + if (isLayout || isPage) { + let layerMod = await item.mod() + + // Layer is a client component, we just skip it. It can't have metadata + // exported. Note that during our SWC transpilation, it should check if + // the exports are valid and give specific error messages. + if ( + '$$typeof' in layerMod && + (layerMod as any).$$typeof === Symbol.for('react.module.reference') + ) { + continue + } + + if (layerMod.metadata && layerMod.generateMetadata) { + throw new Error( + `A ${item.type} is exporting both metadata and generateMetadata which is not supported. If all of the metadata you want to associate to this ${item.type} is static use the metadata export, otherwise use generateMetadata. File: ` + + item.path + ) + } + + // If we resolved all items in this layer, commit the stashed titles. + if (item.layer >= lastLayer) { + committedTitleTemplate = resolvedMetadata.title?.template || null + committedOpenGraphTitleTemplate = + resolvedMetadata.openGraph?.title?.template || null + committedTwitterTitleTemplate = + resolvedMetadata.twitter?.title?.template || null + + lastLayer = item.layer + } + + if (layerMod.metadata) { + merge(resolvedMetadata, layerMod.metadata, { + title: committedTitleTemplate, + openGraph: committedOpenGraphTitleTemplate, + twitter: committedTwitterTitleTemplate, + }) + } else if (layerMod.generateMetadata) { + merge( + resolvedMetadata, + await layerMod.generateMetadata( + // TODO: Rewrite this to pass correct params and resolving metadata value. + {}, + Promise.resolve(resolvedMetadata) + ), + { + title: committedTitleTemplate, + openGraph: committedOpenGraphTitleTemplate, + twitter: committedTwitterTitleTemplate, + } + ) + } + } + } + + return resolvedMetadata +} + +// TODO: Implement this function. +export async function resolveFileBasedMetadataForLoader( + _layer: number, + _dir: string +) { + let metadataCode = '' + + // const files = await fs.readdir(path.normalize(dir)) + // for (const file of files) { + // // TODO: Get a full list and filter out directories. + // if (file === 'icon.svg') { + // metadataCode += `{ + // type: 'icon', + // layer: ${layer}, + // path: ${JSON.stringify(path.join(dir, file))}, + // },` + // } else if (file === 'icon.jsx') { + // metadataCode += `{ + // type: 'icon', + // layer: ${layer}, + // mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify( + // path.join(dir, file) + // )}), + // path: ${JSON.stringify(path.join(dir, file))}, + // },` + // } + // } + + return metadataCode +} diff --git a/packages/next/src/lib/metadata/resolve-opengraph.ts b/packages/next/src/lib/metadata/resolve-opengraph.ts new file mode 100644 index 00000000000000..c322baaa32536c --- /dev/null +++ b/packages/next/src/lib/metadata/resolve-opengraph.ts @@ -0,0 +1,92 @@ +import type { Metadata } from './types/metadata-interface' +import type { + OpenGraphType, + OpenGraph, + ResolvedOpenGraph, +} from './types/opengraph-types' + +const OgTypFields = { + article: ['authors', 'tags'], + song: ['albums', 'musicians'], + playlist: ['albums', 'musicians'], + radio: ['creators'], + video: ['actors', 'directors', 'writers', 'tags'], + basic: [ + 'emails', + 'phoneNumbers', + 'faxNumbers', + 'alternateLocale', + 'images', + 'audio', + 'videos', + ], +} as const + +function resolveAsArrayOrUndefined(value: T): undefined | any[] { + if (typeof value === 'undefined' || value === null) { + return undefined + } + if (Array.isArray(value)) { + return value + } + return [value] +} + +function getFieldsByOgType(ogType: OpenGraphType | undefined) { + switch (ogType) { + case 'article': + case 'book': + return OgTypFields.article + case 'music.song': + case 'music.album': + return OgTypFields.song + case 'music.playlist': + return OgTypFields.playlist + case 'music.radio_station': + return OgTypFields.radio + case 'video.movie': + case 'video.episode': + return OgTypFields.video + default: + return OgTypFields.basic + } +} + +export function resolveOpenGraph( + openGraph: Metadata['openGraph'] +): ResolvedOpenGraph { + const url = openGraph + ? typeof openGraph.url === 'string' + ? new URL(openGraph.url) + : openGraph.url + : undefined + + // TODO: improve typing + const resolved: { [x: string]: any } = openGraph || {} + + function assignProps(og: OpenGraph) { + const ogType = og && 'type' in og ? og.type : undefined + const keys = getFieldsByOgType(ogType) + for (const k of keys) { + const key = k as keyof OpenGraph + if (key in og) { + // TODO: fix typing inferring + // @ts-ignore + const value = resolveAsArrayOrUndefined(og[key]) + if (value != null) { + ;(resolved as any)[key] = value + } + } + } + } + + if (openGraph) { + assignProps(openGraph) + } + + if (url) { + resolved.url = url + } + + return resolved as ResolvedOpenGraph +} diff --git a/packages/next/src/lib/metadata/resolve-title.ts b/packages/next/src/lib/metadata/resolve-title.ts new file mode 100644 index 00000000000000..fae87a60b25b77 --- /dev/null +++ b/packages/next/src/lib/metadata/resolve-title.ts @@ -0,0 +1,41 @@ +import type { Metadata } from './types/metadata-interface' +import type { AbsoluteTemplateString } from './types/metadata-types' + +function resolveTitleTemplate(template: string | null, title: string) { + return template ? template.replace(/%s/g, title) : title +} + +export function mergeTitle( + source: T, + stashedTemplate: string | null +) { + const { title } = source + + let resolved + const template = + typeof source.title !== 'string' && + source.title && + 'template' in source.title + ? source.title.template + : null + + if (typeof title === 'string') { + resolved = resolveTitleTemplate(stashedTemplate, title) + } else if (title) { + if ('default' in title) { + resolved = resolveTitleTemplate(stashedTemplate, title.default) + } + if ('absolute' in title && title.absolute) { + resolved = title.absolute + } + } + + const target = source + if (source.title && typeof source.title !== 'string') { + const targetTitle = source.title as AbsoluteTemplateString + targetTitle.template = template + targetTitle.absolute = resolved || '' + } else { + target.title = { absolute: resolved || source.title || '', template } + } +} diff --git a/packages/next/src/lib/metadata/types/alternative-urls-types.ts b/packages/next/src/lib/metadata/types/alternative-urls-types.ts new file mode 100644 index 00000000000000..ad4938df9feb5e --- /dev/null +++ b/packages/next/src/lib/metadata/types/alternative-urls-types.ts @@ -0,0 +1,261 @@ +// Reference: https://hreflang.org/what-is-a-valid-hreflang + +type LangCode = + | 'af-ZA' + | 'am-ET' + | 'ar-AE' + | 'ar-BH' + | 'ar-DZ' + | 'ar-EG' + | 'ar-IQ' + | 'ar-JO' + | 'ar-KW' + | 'ar-LB' + | 'ar-LY' + | 'ar-MA' + | 'arn-CL' + | 'ar-OM' + | 'ar-QA' + | 'ar-SA' + | 'ar-SD' + | 'ar-SY' + | 'ar-TN' + | 'ar-YE' + | 'as-IN' + | 'az-az' + | 'az-Cyrl-AZ' + | 'az-Latn-AZ' + | 'ba-RU' + | 'be-BY' + | 'bg-BG' + | 'bn-BD' + | 'bn-IN' + | 'bo-CN' + | 'br-FR' + | 'bs-Cyrl-BA' + | 'bs-Latn-BA' + | 'ca-ES' + | 'co-FR' + | 'cs-CZ' + | 'cy-GB' + | 'da-DK' + | 'de-AT' + | 'de-CH' + | 'de-DE' + | 'de-LI' + | 'de-LU' + | 'dsb-DE' + | 'dv-MV' + | 'el-CY' + | 'el-GR' + | 'en-029' + | 'en-AU' + | 'en-BZ' + | 'en-CA' + | 'en-cb' + | 'en-GB' + | 'en-IE' + | 'en-IN' + | 'en-JM' + | 'en-MT' + | 'en-MY' + | 'en-NZ' + | 'en-PH' + | 'en-SG' + | 'en-TT' + | 'en-US' + | 'en-ZA' + | 'en-ZW' + | 'es-AR' + | 'es-BO' + | 'es-CL' + | 'es-CO' + | 'es-CR' + | 'es-DO' + | 'es-EC' + | 'es-ES' + | 'es-GT' + | 'es-HN' + | 'es-MX' + | 'es-NI' + | 'es-PA' + | 'es-PE' + | 'es-PR' + | 'es-PY' + | 'es-SV' + | 'es-US' + | 'es-UY' + | 'es-VE' + | 'et-EE' + | 'eu-ES' + | 'fa-IR' + | 'fi-FI' + | 'fil-PH' + | 'fo-FO' + | 'fr-BE' + | 'fr-CA' + | 'fr-CH' + | 'fr-FR' + | 'fr-LU' + | 'fr-MC' + | 'fy-NL' + | 'ga-IE' + | 'gd-GB' + | 'gd-ie' + | 'gl-ES' + | 'gsw-FR' + | 'gu-IN' + | 'ha-Latn-NG' + | 'he-IL' + | 'hi-IN' + | 'hr-BA' + | 'hr-HR' + | 'hsb-DE' + | 'hu-HU' + | 'hy-AM' + | 'id-ID' + | 'ig-NG' + | 'ii-CN' + | 'in-ID' + | 'is-IS' + | 'it-CH' + | 'it-IT' + | 'iu-Cans-CA' + | 'iu-Latn-CA' + | 'iw-IL' + | 'ja-JP' + | 'ka-GE' + | 'kk-KZ' + | 'kl-GL' + | 'km-KH' + | 'kn-IN' + | 'kok-IN' + | 'ko-KR' + | 'ky-KG' + | 'lb-LU' + | 'lo-LA' + | 'lt-LT' + | 'lv-LV' + | 'mi-NZ' + | 'mk-MK' + | 'ml-IN' + | 'mn-MN' + | 'mn-Mong-CN' + | 'moh-CA' + | 'mr-IN' + | 'ms-BN' + | 'ms-MY' + | 'mt-MT' + | 'nb-NO' + | 'ne-NP' + | 'nl-BE' + | 'nl-NL' + | 'nn-NO' + | 'no-no' + | 'nso-ZA' + | 'oc-FR' + | 'or-IN' + | 'pa-IN' + | 'pl-PL' + | 'prs-AF' + | 'ps-AF' + | 'pt-BR' + | 'pt-PT' + | 'qut-GT' + | 'quz-BO' + | 'quz-EC' + | 'quz-PE' + | 'rm-CH' + | 'ro-mo' + | 'ro-RO' + | 'ru-mo' + | 'ru-RU' + | 'rw-RW' + | 'sah-RU' + | 'sa-IN' + | 'se-FI' + | 'se-NO' + | 'se-SE' + | 'si-LK' + | 'sk-SK' + | 'sl-SI' + | 'sma-NO' + | 'sma-SE' + | 'smj-NO' + | 'smj-SE' + | 'smn-FI' + | 'sms-FI' + | 'sq-AL' + | 'sr-BA' + | 'sr-CS' + | 'sr-Cyrl-BA' + | 'sr-Cyrl-CS' + | 'sr-Cyrl-ME' + | 'sr-Cyrl-RS' + | 'sr-Latn-BA' + | 'sr-Latn-CS' + | 'sr-Latn-ME' + | 'sr-Latn-RS' + | 'sr-ME' + | 'sr-RS' + | 'sr-sp' + | 'sv-FI' + | 'sv-SE' + | 'sw-KE' + | 'syr-SY' + | 'ta-IN' + | 'te-IN' + | 'tg-Cyrl-TJ' + | 'th-TH' + | 'tk-TM' + | 'tlh-QS' + | 'tn-ZA' + | 'tr-TR' + | 'tt-RU' + | 'tzm-Latn-DZ' + | 'ug-CN' + | 'uk-UA' + | 'ur-PK' + | 'uz-Cyrl-UZ' + | 'uz-Latn-UZ' + | 'uz-uz' + | 'vi-VN' + | 'wo-SN' + | 'xh-ZA' + | 'yo-NG' + | 'zh-CN' + | 'zh-HK' + | 'zh-MO' + | 'zh-SG' + | 'zh-TW' + | 'zu-ZA' + +type UnmatchedLang = 'x-default' + +type HrefLang = LangCode | UnmatchedLang + +type Languages = { + [s in HrefLang]?: T +} + +export type AlternateURLs = { + canonical?: null | string | URL + languages?: Languages + media?: { + [media: string]: null | string | URL + } + types?: { + [types: string]: null | string | URL + } +} + +export type ResolvedAlternateURLs = { + canonical: null | URL + languages: Languages + media?: { + [media: string]: null | URL + } + types?: { + [types: string]: null | URL + } +} diff --git a/packages/next/src/lib/metadata/types/extra-types.ts b/packages/next/src/lib/metadata/types/extra-types.ts new file mode 100644 index 00000000000000..51001e695d5f20 --- /dev/null +++ b/packages/next/src/lib/metadata/types/extra-types.ts @@ -0,0 +1,84 @@ +// When rendering applink meta tags add a namespace tag before each array instance +// if more than one member exists. +// ref: https://developers.facebook.com/docs/applinks/metadata-reference + +export type AppLinks = { + ios?: AppLinksApple | Array + iphone?: AppLinksApple | Array + ipad?: AppLinksApple | Array + android?: AppLinksAndroid | Array + windows_phone?: AppLinksWindows | Array + windows?: AppLinksWindows | Array + windows_universal?: AppLinksWindows | Array + web?: AppLinksWeb | Array +} +export type AppLinksApple = { + url: string | URL + app_store_id?: string | number + app_name?: string +} +export type AppLinksAndroid = { + package: string + url?: string | URL + class?: string + app_name?: string +} +export type AppLinksWindows = { + url: string | URL + app_id?: string + app_name?: string +} +export type AppLinksWeb = { + url: string | URL + should_fallback?: boolean +} + +// Apple Itunes APp +// https://developer.apple.com/documentation/webkit/promoting_apps_with_smart_app_banners +export type ItunesApp = { + appId: string + appArgument?: string +} + +// Viewport meta structure +// https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag +// intentionally leaving out user-scalable, use a string if you want that behavior +export type Viewport = { + width?: string | number + height?: string | number + initialScale?: number + minimumScale?: number + maximumScale?: number +} + +// Apple Web App +// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html +// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html +export type AppleWebApp = { + // default true + capable?: boolean + title?: string + startupImage?: AppleImage | Array + // default "default" + statusBarStyle?: 'default' | 'black' | 'black-translucent' +} +export type AppleImage = string | AppleImageDescriptor +export type AppleImageDescriptor = { + url: string + media?: string +} + +// Format Detection +// This is a poorly specified metadata export type that is supposed to +// control whether the device attempts to conver text that matches +// certain formats into links for action. The most supported example +// is how mobile devices detect phone numbers and make them into links +// that can initiate a phone call +// https://www.goodemailcode.com/email-code/template.html +export type FormatDetection = { + telephone?: boolean + date?: boolean + address?: boolean + email?: boolean + url?: boolean +} diff --git a/packages/next/src/lib/metadata/types/manifest-types.ts b/packages/next/src/lib/metadata/types/manifest-types.ts new file mode 100644 index 00000000000000..aa972679d4ae83 --- /dev/null +++ b/packages/next/src/lib/metadata/types/manifest-types.ts @@ -0,0 +1,3 @@ +export type Manifest = { + // fill this out +} diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts new file mode 100644 index 00000000000000..6b073730196075 --- /dev/null +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -0,0 +1,204 @@ +import type { + AlternateURLs, + ResolvedAlternateURLs, +} from './alternative-urls-types' +import type { + AppleWebApp, + AppLinks, + FormatDetection, + ItunesApp, + Viewport, +} from './extra-types' +import type { + AbsoluteTemplateString, + Author, + ColorSchemeEnum, + Icon, + Icons, + ReferrerEnum, + Robots, + TemplateString, + Verification, +} from './metadata-types' +import type { OpenGraph, ResolvedOpenGraph } from './opengraph-types' +import { ResolvedTwitterMetadata, Twitter } from './twitter-types' + +export interface Metadata { + // origin and base path for absolute urls for various metadata links such as + // opengraph-image + metadataBase: null | URL + + // The Document title + title?: null | string | TemplateString + + // The Document description, and optionally the opengraph and twitter descriptions + description?: null | string + + // Standard metadata names + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name + applicationName?: null | string | Array + authors?: null | Author | Array + generator?: null | string + // if you provide an array it will be flattened into a single tag with comma separation + keywords?: null | string | Array + referrer?: null | ReferrerEnum + themeColor?: null | string + colorScheme?: null | ColorSchemeEnum + viewport?: null | string | Viewport + creator?: null | string + publisher?: null | string + + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names + robots?: null | string | Robots + + // The canonical and alternate URLs for this location + alternates: AlternateURLs + + // Defaults to rel="icon" but the Icons type can be used + // to get more specific about rel types + icons?: null | Array | Icons + + openGraph?: null | OpenGraph + + twitter?: null | Twitter + + // common verification tokens + verification?: Verification + + // Apple web app metadata + // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html + appleWebApp?: null | boolean | AppleWebApp + + // Should devices try to interpret various formats and make actionable links + // out of them? The canonical example is telephone numbers on mobile that can + // be clicked to dial + formatDetection?: null | FormatDetection + + // meta name="apple-itunes-app" + itunes?: null | ItunesApp + + // meta name="abstract" + // A brief description of what this web-page is about. + // Not recommended, superceded by description. + // https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/ + abstract?: null | string + + // Facebook AppLinks + appLinks?: null | AppLinks + + // link rel properties + archives?: null | string | Array + assets?: null | string | Array + bookmarks?: null | string | Array // This is technically against HTML spec but is used in wild + + // meta name properties + category?: null | string + classification?: null | string + + // Arbitrary name/value pairs + other?: { + [name: string]: string | number | Array + } + + /** + * Deprecated options that have a preferred method + * */ + // Use appWebApp to configure apple-mobile-web-app-capable which provides + // https://www.appsloveworld.com/coding/iphone/11/difference-between-apple-mobile-web-app-capable-and-apple-touch-fullscreen-ipho + 'apple-touch-fullscreen'?: never + + // Obsolete since iOS 7. use icons.apple or "app-touch-icon" instead + // https://web.dev/apple-touch-icon/ + 'apple-touch-icon-precomposed'?: never +} + +export interface ResolvedMetadata { + // origin and base path for absolute urls for various metadata links such as + // opengraph-image + metadataBase: null | URL + + // The Document title and template if defined + title: null | AbsoluteTemplateString + + // The Document description, and optionally the opengraph and twitter descriptions + description: null | string + + // Standard metadata names + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name + applicationName: null | string + authors: null | Array + generator: null | string + // if you provide an array it will be flattened into a single tag with comma separation + keywords: null | Array + referrer: null | ReferrerEnum + themeColor: null | string + colorScheme: null | ColorSchemeEnum + viewport: null | string + creator: null | string + publisher: null | string + + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names + robots: null | string + + // The canonical and alternate URLs for this location + alternates: ResolvedAlternateURLs + + // Defaults to rel="icon" but the Icons type can be used + // to get more specific about rel types + icons: null | Icons + + openGraph: null | ResolvedOpenGraph + + twitter: null | ResolvedTwitterMetadata + + // common verification tokens + verification: Verification + + // Apple web app metadata + // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html + appleWebApp: null | AppleWebApp + + // Should devices try to interpret various formats and make actionable links + // out of them? The canonical example is telephone numbers on mobile that can + // be clicked to dial + formatDetection: null | FormatDetection + + // meta name="apple-itunes-app" + itunes: null | ItunesApp + + // meta name="abstract" + // A brief description of what this web-page is about. + // Not recommended, superceded by description. + // https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/ + abstract: null | string + + // Facebook AppLinks + appLinks: null | AppLinks + + // link rel properties + archives: null | Array + assets: null | Array + bookmarks: null | Array // This is technically against HTML spec but is used in wild + + // meta name properties + category: null | string + classification: null | string + + // Arbitrary name/value pairs + other: { + [name: string]: string | number | Array + } + + /** + * Deprecated options that have a preferred method + * */ + // Use appWebApp to configure apple-mobile-web-app-capable which provides + // https://www.appsloveworld.com/coding/iphone/11/difference-between-apple-mobile-web-app-capable-and-apple-touch-fullscreen-ipho + 'apple-touch-fullscreen'?: never + + // Obsolete since iOS 7. use icons.apple or "app-touch-icon" instead + // https://web.dev/apple-touch-icon/ + 'apple-touch-icon-precomposed'?: never +} + +export type ResolvingMetadata = Promise diff --git a/packages/next/src/lib/metadata/types/metadata-types.ts b/packages/next/src/lib/metadata/types/metadata-types.ts new file mode 100644 index 00000000000000..fbd3b502cae684 --- /dev/null +++ b/packages/next/src/lib/metadata/types/metadata-types.ts @@ -0,0 +1,93 @@ +/** + * + * Metadata types + * + */ +export type TemplateString = + | DefaultTemplateString + | AbsoluteTemplateString + | AbsoluteString +export type DefaultTemplateString = { + default: string + template: string +} +export type AbsoluteTemplateString = { + absolute: string + template: string | null +} +export type AbsoluteString = { + absolute: string +} + +export type Author = { + // renders as + // rel="shortcut icon" + shortcut?: Icon | Array + // rel="apple-touch-icon" + apple?: Icon | Array + // rel inferred from descriptor, defaults to "icon" + other?: Icon | Array +} + +export type Verification = { + google?: null | string | number | Array + yahoo?: null | string | number | Array + // if you ad-hoc additional verification + other?: { + [name: string]: string | number | Array + } +} diff --git a/packages/next/src/lib/metadata/types/opengraph-types.ts b/packages/next/src/lib/metadata/types/opengraph-types.ts new file mode 100644 index 00000000000000..3b90c1c87874d7 --- /dev/null +++ b/packages/next/src/lib/metadata/types/opengraph-types.ts @@ -0,0 +1,267 @@ +import type { AbsoluteTemplateString, TemplateString } from './metadata-types' + +export type OpenGraphType = + | 'article' + | 'book' + | 'music.song' + | 'music.album' + | 'music.playlist' + | 'music.radio_station' + | 'profile' + | 'website' + | 'video.tv_show' + | 'video.other' + | 'video.movie' + | 'video.episode' + +export type OpenGraph = + | OpenGraphWebsite + | OpenGraphArticle + | OpenGraphBook + | OpenGraphProfile + | OpenGraphMusicSong + | OpenGraphMusicAlbum + | OpenGraphMusicPlaylist + | OpenGraphRadioStation + | OpenGraphVideoMovie + | OpenGraphVideoEpisode + | OpenGraphVideoTVShow + | OpenGraphVideoOther + | OpenGraphMetadata + +// update this type to reflect actual locales +type Locale = string + +type OpenGraphMetadata = { + determiner?: 'a' | 'an' | 'the' | 'auto' | '' + title?: TemplateString + description?: string + emails?: string | Array + phoneNumbers?: string | Array + faxNumbers?: string | Array + siteName?: string + locale?: Locale + alternateLocale?: Locale | Array + images?: OGImage | Array + audio?: OGAudio | Array + videos?: OGVideo | Array + url?: string | URL + countryName?: string + ttl?: number +} +type OpenGraphWebsite = OpenGraphMetadata & { + type: 'website' +} +type OpenGraphArticle = OpenGraphMetadata & { + type: 'article' + publishedTime?: string // datetime + modifiedTime?: string // datetime + expirationTime?: string // datetime + authors?: null | string | URL | Array + section?: null | string + tags?: null | string | Array +} +type OpenGraphBook = OpenGraphMetadata & { + type: 'book' + isbn?: null | string + releaseDate?: null | string // datetime + authors?: null | string | URL | Array + tags?: null | string | Array +} +type OpenGraphProfile = OpenGraphMetadata & { + type: 'profile' + firstName?: null | string + lastName?: null | string + username?: null | string + gender?: null | string +} +type OpenGraphMusicSong = OpenGraphMetadata & { + type: 'music.song' + duration?: null | number + albums?: null | string | URL | OGAlbum | Array + musicians?: null | string | URL | Array +} +type OpenGraphMusicAlbum = OpenGraphMetadata & { + type: 'music.album' + songs?: null | string | URL | OGSong | Array + musicians?: null | string | URL | Array + releaseDate?: null | string // datetime +} +type OpenGraphMusicPlaylist = OpenGraphMetadata & { + type: 'music.playlist' + songs?: null | string | URL | OGSong | Array + creators?: null | string | URL | Array +} +type OpenGraphRadioStation = OpenGraphMetadata & { + type: 'music.radio_station' + creators?: null | string | URL | Array +} +type OpenGraphVideoMovie = OpenGraphMetadata & { + type: 'video.movie' + actors?: null | string | URL | OGActor | Array + directors?: null | string | URL | Array + writers?: null | string | URL | Array + duration?: null | number + releaseDate?: null | string // datetime + tags?: null | string | Array +} +type OpenGraphVideoEpisode = OpenGraphMetadata & { + type: 'video.episode' + actors?: null | string | URL | OGActor | Array + directors?: null | string | URL | Array + writers?: null | string | URL | Array + duration?: null | number + releaseDate?: null | string // datetime + tags?: null | string | Array + series?: null | string | URL +} +type OpenGraphVideoTVShow = OpenGraphMetadata & { + type: 'video.tv_show' +} +type OpenGraphVideoOther = OpenGraphMetadata & { + type: 'video.other' +} + +type OGImage = string | OGImageDescriptor | URL +type OGImageDescriptor = { + url: string | URL + secureUrl?: string | URL + alt?: string + type?: string + width?: string | number + height?: string | number +} +type OGAudio = string | OGAudioDescriptor | URL +type OGAudioDescriptor = { + url: string | URL + secure_url?: string | URL + type?: string +} +type OGVideo = string | OGVideoDescriptor | URL +type OGVideoDescriptor = { + url: string | URL + secureUrl?: string | URL + type?: string + width?: string | number + height?: string | number +} + +export type ResolvedOpenGraph = + | ResolvedOpenGraphWebsite + | ResolvedOpenGraphArticle + | ResolvedOpenGraphBook + | ResolvedOpenGraphProfile + | ResolvedOpenGraphMusicSong + | ResolvedOpenGraphMusicAlbum + | ResolvedOpenGraphMusicPlaylist + | ResolvedOpenGraphRadioStation + | ResolvedOpenGraphVideoMovie + | ResolvedOpenGraphVideoEpisode + | ResolvedOpenGraphVideoTVShow + | ResolvedOpenGraphVideoOther + | ResolvedOpenGraphMetadata + +type ResolvedOpenGraphMetadata = { + determiner?: 'a' | 'an' | 'the' | 'auto' | '' + title?: AbsoluteTemplateString + description?: string + emails?: Array + phoneNumbers?: Array + faxNumbers?: Array + siteName?: string + locale?: Locale + alternateLocale?: Array + images?: Array + audio?: Array + videos?: Array + url?: URL + countryName?: string + ttl?: number +} +type ResolvedOpenGraphWebsite = ResolvedOpenGraphMetadata & { + type: 'website' +} +type ResolvedOpenGraphArticle = ResolvedOpenGraphMetadata & { + type: 'article' + publishedTime?: string // datetime + modifiedTime?: string // datetime + expirationTime?: string // datetime + authors?: Array + section?: string + tags?: Array +} +type ResolvedOpenGraphBook = ResolvedOpenGraphMetadata & { + type: 'book' + isbn?: string + releaseDate?: string // datetime + authors?: Array + tags?: Array +} +type ResolvedOpenGraphProfile = ResolvedOpenGraphMetadata & { + type: 'profile' + firstName?: string + lastName?: string + username?: string + gender?: string +} +type ResolvedOpenGraphMusicSong = ResolvedOpenGraphMetadata & { + type: 'music.song' + duration?: number + albums?: Array + musicians?: Array +} +type ResolvedOpenGraphMusicAlbum = ResolvedOpenGraphMetadata & { + type: 'music.album' + songs?: Array + musicians?: Array + releaseDate?: string // datetime +} +type ResolvedOpenGraphMusicPlaylist = ResolvedOpenGraphMetadata & { + type: 'music.playlist' + songs?: Array + creators?: Array +} +type ResolvedOpenGraphRadioStation = ResolvedOpenGraphMetadata & { + type: 'music.radio_station' + creators?: Array +} +type ResolvedOpenGraphVideoMovie = ResolvedOpenGraphMetadata & { + type: 'video.movie' + actors?: Array + directors?: Array + writers?: Array + duration?: number + releaseDate?: string // datetime + tags?: Array +} +type ResolvedOpenGraphVideoEpisode = ResolvedOpenGraphMetadata & { + type: 'video.episode' + actors?: Array + directors?: Array + writers?: Array + duration?: number + releaseDate?: string // datetime + tags?: Array + series?: string | URL +} +type ResolvedOpenGraphVideoTVShow = ResolvedOpenGraphMetadata & { + type: 'video.tv_show' +} +type ResolvedOpenGraphVideoOther = ResolvedOpenGraphMetadata & { + type: 'video.other' +} + +type OGSong = { + url: string | URL + disc?: number + track?: number +} +type OGAlbum = { + url: string | URL + disc?: number + track?: number +} +type OGActor = { + url: string | URL + role?: string +} diff --git a/packages/next/src/lib/metadata/types/twitter-types.ts b/packages/next/src/lib/metadata/types/twitter-types.ts new file mode 100644 index 00000000000000..f0666d9f64ebd5 --- /dev/null +++ b/packages/next/src/lib/metadata/types/twitter-types.ts @@ -0,0 +1,66 @@ +import type { AbsoluteTemplateString, TemplateString } from './metadata-types' + +export type Twitter = + | TwitterSummary + | TwitterSummaryLargeImage + | TwitterPlayer + | TwitterApp + | TwitterMetadata + +type TwitterMetadata = { + // defaults to card="summary" + site?: string // username for account associated to the site itself + siteId?: string // id for account associated to the site itself + creator?: string // username for the account associated to the creator of the content on the site + creatorId?: string // id for the account associated to the creator of the content on the site + title?: string | TemplateString + description?: string + images?: TwitterImage | Array +} +type TwitterSummary = TwitterMetadata & { + card: 'summary' +} +type TwitterSummaryLargeImage = TwitterMetadata & { + card: 'summary_large_image' +} +type TwitterPlayer = TwitterMetadata & { + card: 'player' + players: TwitterPlayerDescriptor | Array +} +type TwitterApp = TwitterMetadata & { + card: 'app' + app: TwitterAppDescriptor +} +type TwitterAppDescriptor = { + id: { + iphone?: string | number + ipad?: string | number + googleplay?: string + } + url?: { + iphone?: string | URL + ipad?: string | URL + googleplay?: string | URL + } + country?: string +} + +type TwitterImage = string | TwitterImageDescriptor | URL +type TwitterImageDescriptor = { + url: string | URL + secureUrl?: string | URL + alt?: string + type?: string + width?: string | number + height?: string | number +} +type TwitterPlayerDescriptor = { + playerUrl: string | URL + streamUrl: string | URL + width: number + height: number +} + +export type ResolvedTwitterMetadata = Omit & { + title: AbsoluteTemplateString | null +} diff --git a/packages/next/src/lib/metadata/ui.tsx b/packages/next/src/lib/metadata/ui.tsx new file mode 100644 index 00000000000000..b79dfca145c178 --- /dev/null +++ b/packages/next/src/lib/metadata/ui.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +import type { ResolvedMetadata } from './types/metadata-interface' +import { ResolvedBasicMetadata } from './generate/basic' +import { ResolvedAlternatesMetadata } from './generate/alternate' +import { ResolvedOpenGraphMetadata } from './generate/opengraph' +import { resolveMetadata } from './resolve-metadata' + +// Generate the actual React elements from the resolved metadata. +export async function Metadata({ metadata }: { metadata: any }) { + const resolved: ResolvedMetadata = await resolveMetadata(metadata) + return ( + <> + + + + + ) +} diff --git a/packages/next/src/server/app-render.tsx b/packages/next/src/server/app-render.tsx index 107fbd9a970571..d8c3aacc3b67eb 100644 --- a/packages/next/src/server/app-render.tsx +++ b/packages/next/src/server/app-render.tsx @@ -45,8 +45,8 @@ import { RSC, } from '../client/components/app-router-headers' import type { StaticGenerationAsyncStorage } from '../client/components/static-generation-async-storage' -import { DefaultHead } from '../client/components/head' import { formatServerError } from '../lib/format-server-error' +import { Metadata } from '../lib/metadata/ui' import type { RequestAsyncStorage } from '../client/components/request-async-storage' import { runWithRequestAsyncStorage } from './run-with-request-async-storage' import { runWithStaticGenerationAsyncStorage } from './run-with-static-generation-async-storage' @@ -293,11 +293,11 @@ function patchFetch(ComponentMod: any) { } if ( - !staticGenerationStore.fetchRevalidate || + !staticGenerationStore.revalidate || (typeof revalidate === 'number' && - revalidate < staticGenerationStore.fetchRevalidate) + revalidate < staticGenerationStore.revalidate) ) { - staticGenerationStore.fetchRevalidate = revalidate + staticGenerationStore.revalidate = revalidate } let cacheKey: string | undefined @@ -399,39 +399,47 @@ function patchFetch(ComponentMod: any) { delete init.cache } if (cache === 'no-store') { - staticGenerationStore.fetchRevalidate = 0 + staticGenerationStore.revalidate = 0 // TODO: ensure this error isn't logged to the user // seems it's slipping through currently - throw new DynamicServerError( - `no-store fetch ${input}${ - staticGenerationStore.pathname - ? ` ${staticGenerationStore.pathname}` - : '' - }` - ) + const dynamicUsageReason = `no-store fetch ${input}${ + staticGenerationStore.pathname + ? ` ${staticGenerationStore.pathname}` + : '' + }` + const err = new DynamicServerError(dynamicUsageReason) + staticGenerationStore.dynamicUsageStack = err.stack + staticGenerationStore.dynamicUsageDescription = dynamicUsageReason + + throw err } const hasNextConfig = 'next' in init const next = init.next || {} if ( typeof next.revalidate === 'number' && - (typeof staticGenerationStore.fetchRevalidate === 'undefined' || - next.revalidate < staticGenerationStore.fetchRevalidate) + (typeof staticGenerationStore.revalidate === 'undefined' || + next.revalidate < staticGenerationStore.revalidate) ) { const forceDynamic = staticGenerationStore.forceDynamic if (!forceDynamic || next.revalidate !== 0) { - staticGenerationStore.fetchRevalidate = next.revalidate + staticGenerationStore.revalidate = next.revalidate } if (!forceDynamic && next.revalidate === 0) { - throw new DynamicServerError( - `revalidate: ${next.revalidate} fetch ${input}${ - staticGenerationStore.pathname - ? ` ${staticGenerationStore.pathname}` - : '' - }` - ) + const dynamicUsageReason = `revalidate: ${ + next.revalidate + } fetch ${input}${ + staticGenerationStore.pathname + ? ` ${staticGenerationStore.pathname}` + : '' + }` + const err = new DynamicServerError(dynamicUsageReason) + staticGenerationStore.dynamicUsageStack = err.stack + staticGenerationStore.dynamicUsageDescription = dynamicUsageReason + + throw err } } if (hasNextConfig) delete init.next @@ -967,6 +975,12 @@ export async function renderToHTMLOrFlight( */ const loaderTree: LoaderTree = ComponentMod.tree + /** + * The metadata items array created in next-app-loader with all relevant information + * that we need to resolve the final metadata. + */ + const metadataItems = ComponentMod.metadata + stripInternalQueries(query) const LayoutRouter = @@ -1050,8 +1064,7 @@ export async function renderToHTMLOrFlight( async function resolveHead( [segment, parallelRoutes, { head }]: LoaderTree, - parentParams: { [key: string]: any }, - isRootHead: boolean + parentParams: { [key: string]: any } ): Promise { // Handle dynamic segment params. const segmentParam = getDynamicParamFromSegment(segment) @@ -1069,7 +1082,7 @@ export async function renderToHTMLOrFlight( parentParams for (const key in parallelRoutes) { const childTree = parallelRoutes[key] - const returnedHead = await resolveHead(childTree, currentParams, false) + const returnedHead = await resolveHead(childTree, currentParams) if (returnedHead) { return returnedHead } @@ -1078,8 +1091,6 @@ export async function renderToHTMLOrFlight( if (head) { const Head = await interopDefault(await head[0]()) return - } else if (isRootHead) { - return } return null @@ -1288,7 +1299,11 @@ export async function renderToHTMLOrFlight( const { DynamicServerError } = ComponentMod.serverHooks as typeof import('../client/components/hooks-server-context') - throw new DynamicServerError(`revalidate: 0 configured ${segment}`) + const dynamicUsageDescription = `revalidate: 0 configured ${segment}` + staticGenerationStore.dynamicUsageDescription = + dynamicUsageDescription + + throw new DynamicServerError(dynamicUsageDescription) } } @@ -1682,7 +1697,7 @@ export async function renderToHTMLOrFlight( return [actualSegment] } - const rscPayloadHead = await resolveHead(loaderTree, {}, true) + const rscPayloadHead = await resolveHead(loaderTree, {}) // 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. const flightData: FlightData = [ @@ -1693,7 +1708,13 @@ export async function renderToHTMLOrFlight( parentParams: {}, flightRouterState: providedFlightRouterState, isFirst: true, - rscPayloadHead, + rscPayloadHead: ( + <> + {/* @ts-expect-error allow to use async server component */} + + {rscPayloadHead} + + ), injectedCSS: new Set(), rootLayoutIncluded: false, }) @@ -1760,7 +1781,7 @@ export async function renderToHTMLOrFlight( } : {} - const initialHead = await resolveHead(loaderTree, {}, true) + const initialHead = await resolveHead(loaderTree, {}) /** * A new React Component that renders the provided React Component @@ -1777,18 +1798,27 @@ export async function renderToHTMLOrFlight( injectedCSS: new Set(), rootLayoutIncluded: false, }) + const initialTree = createFlightRouterStateFromLoaderTree(loaderTree) return ( - - - + <> + + {/* @ts-expect-error allow to use async server component */} + + {initialHead} + + } + globalErrorComponent={GlobalError} + > + + + ) }, ComponentMod, @@ -1959,7 +1989,7 @@ export async function renderToHTMLOrFlight( ) if (staticGenerationStore.forceStatic === false) { - staticGenerationStore.fetchRevalidate = 0 + staticGenerationStore.revalidate = 0 } // TODO: investigate why `pageData` is not in RenderOpts @@ -1967,7 +1997,15 @@ export async function renderToHTMLOrFlight( // TODO: investigate why `revalidate` is not in RenderOpts ;(renderOpts as any).revalidate = - staticGenerationStore.fetchRevalidate ?? defaultRevalidate + staticGenerationStore.revalidate ?? defaultRevalidate + + // provide bailout info for debugging + if ((renderOpts as any).revalidate === 0) { + ;(renderOpts as any).staticBailoutInfo = { + description: staticGenerationStore.dynamicUsageDescription, + stack: staticGenerationStore.dynamicUsageStack, + } + } return new RenderResult(htmlResult) } diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index c029e2b27bb181..e9e157079b43ed 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1374,9 +1374,26 @@ export default abstract class Server { isRedirect = (renderOpts as any).isRedirect if (isAppPath && isSSG && isrRevalidate === 0) { - throw new Error( - `Page changed from static to dynamic at runtime ${urlPathname}, see more here https://nextjs.org/docs/messages/app-static-to-dynamic-error` + const staticBailoutInfo: { + stack?: string + description?: string + } = (renderOpts as any).staticBailoutInfo || {} + + const err = new Error( + `Page changed from static to dynamic at runtime ${urlPathname}${ + staticBailoutInfo.description + ? `, reason: ${staticBailoutInfo.description}` + : `` + }` + + `\nsee more here https://nextjs.org/docs/messages/app-static-to-dynamic-error` ) + + if (staticBailoutInfo.stack) { + const stack = staticBailoutInfo.stack as string + err.stack = err.message + stack.substring(stack.indexOf('\n')) + } + + throw err } let value: ResponseCacheValue | null diff --git a/packages/next/src/shared/lib/loadable.tsx b/packages/next/src/shared/lib/loadable.tsx index d4758716cc178f..dfded2be5856ee 100644 --- a/packages/next/src/shared/lib/loadable.tsx +++ b/packages/next/src/shared/lib/loadable.tsx @@ -68,8 +68,6 @@ function createLoadableComponent(loadFn: any, options: any) { options ) - opts.lazy = React.lazy(opts.loader) - /** @type LoadableSubscription */ let subscription: any = null function init() { @@ -86,6 +84,18 @@ function createLoadableComponent(loadFn: any, options: any) { return subscription.promise() } + opts.lazy = React.lazy(async () => { + // If dynamic options.ssr == true during SSR, + // passing the preloaded promise of component to `React.lazy`. + // This guarantees the loader is always resolved after preloading. + if (opts.ssr && subscription) { + const value = subscription.getCurrentValue() + const resolved = await value.loaded + if (resolved) return resolved + } + return await opts.loader() + }) + // Server only if (typeof window === 'undefined') { ALL_INITIALIZERS.push(init) diff --git a/packages/next/src/shared/lib/router/router.ts b/packages/next/src/shared/lib/router/router.ts index 7fc6446ed4fc43..216af5fef2093f 100644 --- a/packages/next/src/shared/lib/router/router.ts +++ b/packages/next/src/shared/lib/router/router.ts @@ -672,14 +672,23 @@ interface FetchNextDataParams { unstable_skipClientCache?: boolean } -function handleSmoothScroll(fn: () => void) { +/** + * Run function with `scroll-behavior: auto` applied to ``. + * This css change will be reverted after the function finishes. + */ +export function handleSmoothScroll( + fn: () => void, + options: { dontForceLayout?: boolean } = {} +) { const htmlElement = document.documentElement const existing = htmlElement.style.scrollBehavior htmlElement.style.scrollBehavior = 'auto' - // In Chrome-based browsers we need to force reflow before calling `scrollTo`. - // Otherwise it will not pickup the change in scrollBehavior - // More info here: https://github.com/vercel/next.js/issues/40719#issuecomment-1336248042 - htmlElement.getClientRects() + if (!options.dontForceLayout) { + // In Chrome-based browsers we need to force reflow before calling `scrollTo`. + // Otherwise it will not pickup the change in scrollBehavior + // More info here: https://github.com/vercel/next.js/issues/40719#issuecomment-1336248042 + htmlElement.getClientRects() + } fn() htmlElement.style.scrollBehavior = existing } diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index 64ae8a5103a40b..a1799e1e8a303b 100644 --- a/packages/react-dev-overlay/package.json +++ b/packages/react-dev-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-dev-overlay", - "version": "13.1.5-canary.2", + "version": "13.1.6-canary.0", "description": "A development-only overlay for developing React applications.", "repository": { "url": "vercel/next.js", diff --git a/packages/react-dev-overlay/src/internal/container/Errors.tsx b/packages/react-dev-overlay/src/internal/container/Errors.tsx index 329436693bc83e..6cfe03656e7f5e 100644 --- a/packages/react-dev-overlay/src/internal/container/Errors.tsx +++ b/packages/react-dev-overlay/src/internal/container/Errors.tsx @@ -49,17 +49,20 @@ const HotlinkedText: React.FC<{ }> = function HotlinkedText(props) { const { text } = props - const linkRegex = /https?:\/\/[^\s/$.?#].[^\s"]*/i + const linkRegex = /https?:\/\/[^\s/$.?#].[^\s)'"]*/i return ( <> {linkRegex.test(text) ? text.split(' ').map((word, index, array) => { if (linkRegex.test(word)) { + const link = linkRegex.exec(word) return ( -
- {word} - + {link && ( + + {word} + + )} {index === array.length - 1 ? '' : ' '} ) diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 863f1a31152796..2f6bbb58247f24 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "13.1.5-canary.2", + "version": "13.1.6-canary.0", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e9a5f4204e865..2a7ea7edd22e48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,7 +442,7 @@ importers: packages/eslint-config-next: specifiers: - '@next/eslint-plugin-next': 13.1.5-canary.2 + '@next/eslint-plugin-next': 13.1.6-canary.0 '@rushstack/eslint-patch': ^1.1.3 '@typescript-eslint/parser': ^5.42.0 eslint: ^7.23.0 || ^8.0.0 @@ -514,12 +514,12 @@ importers: '@hapi/accept': 5.0.2 '@napi-rs/cli': 2.13.3 '@napi-rs/triples': 1.1.0 - '@next/env': 13.1.5-canary.2 - '@next/polyfill-module': 13.1.5-canary.2 - '@next/polyfill-nomodule': 13.1.5-canary.2 - '@next/react-dev-overlay': 13.1.5-canary.2 - '@next/react-refresh-utils': 13.1.5-canary.2 - '@next/swc': 13.1.5-canary.2 + '@next/env': 13.1.6-canary.0 + '@next/polyfill-module': 13.1.6-canary.0 + '@next/polyfill-nomodule': 13.1.6-canary.0 + '@next/react-dev-overlay': 13.1.6-canary.0 + '@next/react-refresh-utils': 13.1.6-canary.0 + '@next/swc': 13.1.6-canary.0 '@segment/ajv-human-errors': 2.1.2 '@swc/helpers': 0.4.14 '@taskr/clear': 1.1.0 diff --git a/test/development/acceptance-app/component-stack.test.ts b/test/development/acceptance-app/component-stack.test.ts new file mode 100644 index 00000000000000..70a6f42a30527f --- /dev/null +++ b/test/development/acceptance-app/component-stack.test.ts @@ -0,0 +1,63 @@ +/* eslint-env jest */ +import { sandbox } from './helpers' +import { createNextDescribe, FileRef } from 'e2e-utils' +import path from 'path' + +createNextDescribe( + 'Component Stack in error overlay', + { + files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), + dependencies: { + react: 'latest', + 'react-dom': 'latest', + }, + skipStart: true, + }, + ({ next }) => { + it('should show a component stack on hydration error', async () => { + const { cleanup, session } = await sandbox( + next, + new Map([ + [ + 'app/component.js', + ` + 'use client' + const isClient = typeof window !== 'undefined' + export default function Component() { + return ( +
+

{isClient ? "client" : "server"}

+
+ ); + } +`, + ], + [ + 'app/page.js', + ` + import Component from './component' + export default function Mismatch() { + return ( +
+ +
+ ); + } +`, + ], + ]) + ) + + await session.waitForAndOpenRuntimeError() + + expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(` + "p + div + Component + main" + `) + + await cleanup() + }) + } +) diff --git a/test/development/acceptance-app/helpers.ts b/test/development/acceptance-app/helpers.ts index 92b3ba0d96ed87..11e6c11a957684 100644 --- a/test/development/acceptance-app/helpers.ts +++ b/test/development/acceptance-app/helpers.ts @@ -121,6 +121,17 @@ export async function sandbox( } return source }, + async getRedboxComponentStack() { + await browser.waitForElementByCss('[data-nextjs-component-stack-frame]') + const componentStackFrameElements = await browser.elementsByCss( + '[data-nextjs-component-stack-frame]' + ) + const componentStackFrameTexts = await Promise.all( + componentStackFrameElements.map((f) => f.innerText()) + ) + + return componentStackFrameTexts.join('\n') + }, async waitForAndOpenRuntimeError() { return browser.waitForElementByCss('[data-nextjs-toast]').click() }, diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index b475255a126256..da125cfb238dc1 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -5,7 +5,7 @@ import path from 'path' // https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js createNextDescribe( - 'Error Overlay for server components', + 'Error overlay for hydration errors', { files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), dependencies: { diff --git a/test/development/acceptance/ReactRefreshLogBox.test.ts b/test/development/acceptance/ReactRefreshLogBox.test.ts index 8f17aa8ba1a360..f54d51d100b11d 100644 --- a/test/development/acceptance/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance/ReactRefreshLogBox.test.ts @@ -928,6 +928,66 @@ for (const variant of ['default', 'turbo']) { ) ).toMatchSnapshot() + await session.patch( + 'index.js', + ` + import { useCallback } from 'react' + + export default function Index() { + const boom = useCallback(() => { + throw new Error('multiple http://nextjs.org links (http://example.com)') + }, []) + return ( +
+ +
+ ) + } + ` + ) + + expect(await session.hasRedbox(false)).toBe(false) + await session.evaluate(() => document.querySelector('button').click()) + expect(await session.hasRedbox(true)).toBe(true) + + const header5 = await session.getRedboxDescription() + expect(header5).toMatchInlineSnapshot( + `"Error: multiple http://nextjs.org links (http://example.com)"` + ) + expect( + await session.evaluate( + () => + document + .querySelector('body > nextjs-portal') + .shadowRoot.querySelectorAll('#nextjs__container_errors_desc a') + .length + ) + ).toBe(2) + expect( + await session.evaluate( + () => + ( + document + .querySelector('body > nextjs-portal') + .shadowRoot.querySelector( + '#nextjs__container_errors_desc a:nth-of-type(1)' + ) as any + ).href + ) + ).toMatchSnapshot() + expect( + await session.evaluate( + () => + ( + document + .querySelector('body > nextjs-portal') + .shadowRoot.querySelector( + '#nextjs__container_errors_desc a:nth-of-type(2)' + ) as any + ).href + ) + ).toMatchSnapshot() + await cleanup() }) diff --git a/test/development/acceptance/__snapshots__/ReactRefreshLogBox.test.ts.snap b/test/development/acceptance/__snapshots__/ReactRefreshLogBox.test.ts.snap index 9c39d836546c0e..cd8cf50e85ab6f 100644 --- a/test/development/acceptance/__snapshots__/ReactRefreshLogBox.test.ts.snap +++ b/test/development/acceptance/__snapshots__/ReactRefreshLogBox.test.ts.snap @@ -43,6 +43,10 @@ exports[`ReactRefreshLogBox default logbox: anchors links in error messages 8`] exports[`ReactRefreshLogBox default logbox: anchors links in error messages 9`] = `"http://example.com/"`; +exports[`ReactRefreshLogBox default logbox: anchors links in error messages 11`] = `"http://nextjs.org/"`; + +exports[`ReactRefreshLogBox default logbox: anchors links in error messages 12`] = `"http://example.com/"`; + exports[`ReactRefreshLogBox default logbox: can recover from a component error 1`] = ` "child.js (4:16) @ Child diff --git a/test/development/basic/next-dynamic.test.ts b/test/development/basic/next-dynamic.test.ts index c0652872bbebac..b32b4fba6f595a 100644 --- a/test/development/basic/next-dynamic.test.ts +++ b/test/development/basic/next-dynamic.test.ts @@ -5,9 +5,39 @@ import { createNext, FileRef } from 'e2e-utils' import { renderViaHTTP, check, hasRedbox } from 'next-test-utils' import { NextInstance } from 'test/lib/next-modes/base' -describe.each([[''], ['/docs']])( - 'basic next/dynamic usage, basePath: %p', - (basePath: string) => { +const customDocumentGipFiles = { + 'pages/_document.js': ` + import { Html, Main, NextScript, Head } from 'next/document' + + export default function Document() { + return ( + + + +
+ + + + ) + } + + Document.getInitialProps = (ctx) => { + return ctx.defaultGetInitialProps(ctx) + } + `, +} + +describe.each([ + ['', 'swc'], + ['/docs', 'swc'], + ['', 'document.getInitialProps'], + ['', 'babel'], +])( + 'basic next/dynamic usage, basePath: %p with %p compiler', + ( + basePath: string, + testCase: 'swc' | 'babel' | 'document.getInitialProps' + ) => { let next: NextInstance beforeAll(async () => { @@ -15,6 +45,11 @@ describe.each([[''], ['/docs']])( files: { components: new FileRef(join(__dirname, 'next-dynamic/components')), pages: new FileRef(join(__dirname, 'next-dynamic/pages')), + ...(testCase === 'document.getInitialProps' && + customDocumentGipFiles), + ...(testCase === 'babel' && { + '.babelrc': `{ "presets": ["next/babel"] }`, + }), }, nextConfig: { basePath, @@ -125,10 +160,7 @@ describe.each([[''], ['/docs']])( let browser try { browser = await webdriver(next.url, basePath + '/dynamic/no-ssr') - await check( - () => browser.elementByCss('body').text(), - /Hello World 1/ - ) + await check(() => browser.elementByCss('body').text(), /navigator/) expect(await hasRedbox(browser, false)).toBe(false) } finally { if (browser) { @@ -167,7 +199,7 @@ describe.each([[''], ['/docs']])( '.next/server/pages/dynamic/no-ssr.js.nft.json' ) ) as { files: string[] } - expect(trace).not.toContain('hello1') + expect(trace).not.toContain('navigator') }) } }) diff --git a/test/development/basic/next-dynamic/components/pure-client.js b/test/development/basic/next-dynamic/components/pure-client.js new file mode 100644 index 00000000000000..440766cdff329c --- /dev/null +++ b/test/development/basic/next-dynamic/components/pure-client.js @@ -0,0 +1,5 @@ +window.ua = navigator.userAgent + +export default function PureClient() { + return

navigator

+} diff --git a/test/development/basic/next-dynamic/pages/dynamic/no-ssr.js b/test/development/basic/next-dynamic/pages/dynamic/no-ssr.js index a0818a395b7ee6..b8b4b01acb5b7e 100644 --- a/test/development/basic/next-dynamic/pages/dynamic/no-ssr.js +++ b/test/development/basic/next-dynamic/pages/dynamic/no-ssr.js @@ -1,5 +1,7 @@ import dynamic from 'next/dynamic' -const Hello = dynamic(import('../../components/hello1'), { ssr: false }) +const PureClient = dynamic(import('../../components/pure-client'), { + ssr: false, +}) -export default Hello +export default PureClient diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index b30c1da967c14f..7e2a16aa88c84e 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -4,6 +4,7 @@ import { promisify } from 'util' import { join } from 'path' import { createNextDescribe } from 'e2e-utils' import { check, normalizeRegEx, waitFor } from 'next-test-utils' +import stripAnsi from 'strip-ansi' const glob = promisify(globOrig) @@ -11,6 +12,9 @@ createNextDescribe( 'app-dir static/dynamic handling', { files: __dirname, + env: { + NEXT_DEBUG_BUILD: '1', + }, }, ({ next, isNextDev: isDev, isNextStart }) => { if (isNextStart) { @@ -272,6 +276,17 @@ createNextDescribe( }, }) }) + + it('should output debug info for static bailouts', async () => { + const cleanedOutput = stripAnsi(next.cliOutput) + + expect(cleanedOutput).toContain( + 'Static generation failed due to dynamic usage on /force-static, reason: headers' + ) + expect(cleanedOutput).toContain( + 'Static generation failed due to dynamic usage on /ssr-auto/cache-no-store, reason: no-store fetch' + ) + }) } if (!isDev) { @@ -284,8 +299,8 @@ createNextDescribe( if (isNextStart) { await check( - () => next.cliOutput, - /Page changed from static to dynamic at runtime \/static-to-dynamic-error\/static-bailout-1/ + () => stripAnsi(next.cliOutput), + /Page changed from static to dynamic at runtime \/static-to-dynamic-error\/static-bailout-1, reason: cookies/ ) } }) @@ -302,8 +317,8 @@ createNextDescribe( expect(html).toMatch(/id:.*?static-bailout-1/) if (isNextStart) { - expect(next.cliOutput.substring(outputIndex)).not.toMatch( - /Page changed from static to dynamic at runtime \/static-to-dynamic-error\/static-bailout-1/ + expect(stripAnsi(next.cliOutput).substring(outputIndex)).not.toMatch( + /Page changed from static to dynamic at runtime \/static-to-dynamic-error-forced\/static-bailout-1, reason: cookies/ ) } }) diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index b9ff9f6bba433f..b8ba84c1def023 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -102,7 +102,7 @@ createNextDescribe( it('should pass props from getServerSideProps in root layout', async () => { const $ = await next.render$('/dashboard') - expect($('title').text()).toBe('hello world') + expect($('title').first().text()).toBe('hello world') }) it('should serve from pages', async () => { diff --git a/test/e2e/app-dir/dynamic/ui/pure-client.js b/test/e2e/app-dir/dynamic/ui/pure-client.js index b636a6f6639472..440766cdff329c 100644 --- a/test/e2e/app-dir/dynamic/ui/pure-client.js +++ b/test/e2e/app-dir/dynamic/ui/pure-client.js @@ -1,4 +1,4 @@ -console.log('navigator.userAgent', navigator.userAgent) +window.ua = navigator.userAgent export default function PureClient() { return

navigator

diff --git a/test/e2e/app-dir/metadata/app/alternate/page.js b/test/e2e/app-dir/metadata/app/alternate/page.js new file mode 100644 index 00000000000000..25777130e9a6f7 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/alternate/page.js @@ -0,0 +1,19 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + alternates: { + canonical: 'https://example.com', + languages: { + 'en-US': 'https://example.com/en-US', + 'de-DE': 'https://example.com/de-DE', + }, + media: { + 'only screen and (max-width: 600px)': 'https://example.com/mobile', + }, + types: { + 'application/rss+xml': 'https://example.com/rss', + }, + }, +} diff --git a/test/e2e/app-dir/metadata/app/basic/page.js b/test/e2e/app-dir/metadata/app/basic/page.js new file mode 100644 index 00000000000000..224dd109889429 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/basic/page.js @@ -0,0 +1,25 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ + to index + +
+ ) +} + +export const metadata = { + generator: 'next.js', + applicationName: 'test', + referrer: 'origin-when-crossorigin', + keywords: ['next.js', 'react', 'javascript'], + authors: ['John Doe', 'Jane Doe'], + themeColor: 'cyan', + colorScheme: 'dark', + viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no', + creator: 'shu', + publisher: 'vercel', + robots: 'index, follow', +} diff --git a/test/e2e/app-dir/metadata/app/layout.js b/test/e2e/app-dir/metadata/app/layout.js new file mode 100644 index 00000000000000..e427799cce6f75 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/layout.js @@ -0,0 +1,13 @@ +export default function Layout({ children }) { + return ( + + + {children} + + ) +} + +export const metadata = { + title: 'this is the layout title', + description: 'this is the layout description', +} diff --git a/test/e2e/app-dir/metadata/app/opengraph/article/page.js b/test/e2e/app-dir/metadata/app/opengraph/article/page.js new file mode 100644 index 00000000000000..4b92b111d6610e --- /dev/null +++ b/test/e2e/app-dir/metadata/app/opengraph/article/page.js @@ -0,0 +1,13 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + openGraph: { + title: 'My custom title', + description: 'My custom description', + type: 'article', + publishedTime: '2023-01-01T00:00:00.000Z', + authors: ['author1', 'author2', 'author3'], + }, +} diff --git a/test/e2e/app-dir/metadata/app/opengraph/page.js b/test/e2e/app-dir/metadata/app/opengraph/page.js new file mode 100644 index 00000000000000..970a521bf382a7 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/opengraph/page.js @@ -0,0 +1,27 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + openGraph: { + title: 'My custom title', + description: 'My custom description', + url: 'https://example.com', + siteName: 'My custom site name', + images: [ + { + url: 'https://example.com/image.png', + width: 800, + height: 600, + }, + { + url: 'https://example.com/image2.png', + width: 1800, + height: 1600, + alt: 'My custom alt', + }, + ], + locale: 'en-US', + type: 'website', + }, +} diff --git a/test/e2e/app-dir/metadata/app/page.js b/test/e2e/app-dir/metadata/app/page.js new file mode 100644 index 00000000000000..202d1179a5c866 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/page.js @@ -0,0 +1,23 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

index page

+ + + to /basic + +
+ + + to /title + +
+ + ) +} + +export const metadata = { + title: 'index page', +} diff --git a/test/e2e/app-dir/metadata/app/title-template/extra/inner/page.js b/test/e2e/app-dir/metadata/app/title-template/extra/inner/page.js new file mode 100644 index 00000000000000..426364f16fb389 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/extra/inner/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + title: 'Inner Page', +} diff --git a/test/e2e/app-dir/metadata/app/title-template/extra/layout.js b/test/e2e/app-dir/metadata/app/title-template/extra/layout.js new file mode 100644 index 00000000000000..93f67bdbf497ef --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/extra/layout.js @@ -0,0 +1,9 @@ +export default function Layout(props) { + return props.children +} + +export const metadata = { + title: { + template: '%s | Extra Layout', + }, +} diff --git a/test/e2e/app-dir/metadata/app/title-template/extra/page.js b/test/e2e/app-dir/metadata/app/title-template/extra/page.js new file mode 100644 index 00000000000000..ea77ded22896d9 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/extra/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + title: 'Extra Page', +} diff --git a/test/e2e/app-dir/metadata/app/title-template/layout.js b/test/e2e/app-dir/metadata/app/title-template/layout.js new file mode 100644 index 00000000000000..adec3c2eb8874a --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/layout.js @@ -0,0 +1,9 @@ +export default function Layout(props) { + return props.children +} + +export const metadata = { + title: { + template: '%s | Layout', + }, +} diff --git a/test/e2e/app-dir/metadata/app/title-template/page.js b/test/e2e/app-dir/metadata/app/title-template/page.js new file mode 100644 index 00000000000000..2fbc7681df1ea7 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + title: 'Page', +} diff --git a/test/e2e/app-dir/metadata/app/title/page.js b/test/e2e/app-dir/metadata/app/title/page.js new file mode 100644 index 00000000000000..9e63975b66874b --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title/page.js @@ -0,0 +1,15 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ + to index + +
+ ) +} + +export const metadata = { + title: 'this is the page title', +} diff --git a/test/e2e/app-dir/metadata/app/viewport/object/page.js b/test/e2e/app-dir/metadata/app/viewport/object/page.js new file mode 100644 index 00000000000000..e82899ae607df1 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/viewport/object/page.js @@ -0,0 +1,11 @@ +export default function Page() { + return

viewport

+} + +export const metadata = { + viewport: { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + }, +} diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts new file mode 100644 index 00000000000000..1d805490533d89 --- /dev/null +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -0,0 +1,214 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'app dir - metadata', + { + files: __dirname, + }, + ({ next, isNextDeploy }) => { + describe('metadata', () => { + if (isNextDeploy) { + it('should skip for deploy currently', () => {}) + return + } + async function checkMeta( + browser, + name, + content, + property = 'property', + tag = 'meta', + field = 'content' + ) { + const values = await browser.eval( + `[...document.querySelectorAll('${tag}[${property}="${name}"]')].map((el) => el.${field})` + ) + if (Array.isArray(content)) { + expect(values).toEqual(content) + } else { + console.log('expect', values[0], 'toContain', content) + expect(values[0]).toContain(content) + } + } + + describe('basic', () => { + it('should support title and description', async () => { + const browser = await next.browser('/title') + expect(await browser.eval(`document.title`)).toBe( + 'this is the page title' + ) + await checkMeta( + browser, + 'description', + 'this is the layout description', + 'name' + ) + }) + + it('should support title template', async () => { + const browser = await next.browser('/title-template') + expect(await browser.eval(`document.title`)).toBe('Page | Layout') + }) + + it('should support stashed title in one layer of page and layout', async () => { + const browser = await next.browser('/title-template/extra') + expect(await browser.eval(`document.title`)).toBe( + 'Extra Page | Extra Layout' + ) + }) + + it('should support stashed title in two layers of page and layout', async () => { + const browser = await next.browser('/title-template/extra/inner') + expect(await browser.eval(`document.title`)).toBe( + 'Inner Page | Extra Layout' + ) + }) + + it('should support other basic tags', async () => { + const browser = await next.browser('/basic') + await checkMeta(browser, 'generator', 'next.js', 'name') + await checkMeta(browser, 'application-name', 'test', 'name') + await checkMeta( + browser, + 'referrer', + 'origin-when-crossorigin', + 'name' + ) + await checkMeta( + browser, + 'keywords', + 'next.js,react,javascript', + 'name' + ) + await checkMeta(browser, 'author', 'John Doe,Jane Doe', 'name') + await checkMeta(browser, 'theme-color', 'cyan', 'name') + await checkMeta(browser, 'color-scheme', 'dark', 'name') + await checkMeta( + browser, + 'viewport', + 'width=device-width, initial-scale=1, shrink-to-fit=no', + 'name' + ) + await checkMeta(browser, 'creator', 'shu', 'name') + await checkMeta(browser, 'publisher', 'vercel', 'name') + await checkMeta(browser, 'robots', 'index, follow', 'name') + }) + + it('should support object viewport', async () => { + const browser = await next.browser('/viewport/object') + await checkMeta( + browser, + 'viewport', + 'width=device-width, initial-scale=1, maximum-scale=1', + 'name' + ) + }) + + it('should support alternate tags', async () => { + const browser = await next.browser('/alternate') + await checkMeta( + browser, + 'canonical', + 'https://example.com', + 'rel', + 'link', + 'href' + ) + await checkMeta( + browser, + 'en-US', + 'https://example.com/en-US', + 'hreflang', + 'link', + 'href' + ) + await checkMeta( + browser, + 'de-DE', + 'https://example.com/de-DE', + 'hreflang', + 'link', + 'href' + ) + await checkMeta( + browser, + 'only screen and (max-width: 600px)', + 'https://example.com/mobile', + 'media', + 'link', + 'href' + ) + await checkMeta( + browser, + 'application/rss+xml', + 'https://example.com/rss', + 'type', + 'link', + 'href' + ) + }) + + it('should apply metadata when navigating client-side', async () => { + const browser = await next.browser('/') + + const getTitle = () => browser.elementByCss('title').text() + + expect(await getTitle()).toBe('index page') + await browser + .elementByCss('#to-basic') + .click() + .waitForElementByCss('#basic', 2000) + + await checkMeta( + browser, + 'referrer', + 'origin-when-crossorigin', + 'name' + ) + await browser.back().waitForElementByCss('#index', 2000) + expect(await getTitle()).toBe('index page') + await browser + .elementByCss('#to-title') + .click() + .waitForElementByCss('#title', 2000) + expect(await getTitle()).toBe('this is the page title') + }) + }) + + describe('opengraph', () => { + it('should support opengraph tags', async () => { + const browser = await next.browser('/opengraph') + await checkMeta(browser, 'og:title', 'My custom title') + await checkMeta(browser, 'og:description', 'My custom description') + await checkMeta(browser, 'og:url', 'https://example.com') + await checkMeta(browser, 'og:site_name', 'My custom site name') + await checkMeta(browser, 'og:locale', 'en-US') + await checkMeta(browser, 'og:type', 'website') + await checkMeta(browser, 'og:image:url', [ + 'https://example.com/image.png', + 'https://example.com/image2.png', + ]) + await checkMeta(browser, 'og:image:width', ['800', '1800']) + await checkMeta(browser, 'og:image:height', ['600', '1600']) + await checkMeta(browser, 'og:image:alt', 'My custom alt') + }) + + it('should support opengraph with article type', async () => { + const browser = await next.browser('/opengraph/article') + await checkMeta(browser, 'og:title', 'My custom title') + await checkMeta(browser, 'og:description', 'My custom description') + await checkMeta(browser, 'og:type', 'article') + await checkMeta( + browser, + 'article:published_time', + '2023-01-01T00:00:00.000Z' + ) + await checkMeta(browser, 'article:author', [ + 'author1', + 'author2', + 'author3', + ]) + }) + }) + }) + } +) diff --git a/test/e2e/app-dir/metadata/next.config.js b/test/e2e/app-dir/metadata/next.config.js new file mode 100644 index 00000000000000..8e2a6c36917446 --- /dev/null +++ b/test/e2e/app-dir/metadata/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + experimental: { appDir: true }, +} diff --git a/test/e2e/app-dir/router-autoscroll.test.ts b/test/e2e/app-dir/router-autoscroll.test.ts new file mode 100644 index 00000000000000..a9cbb2d5f83149 --- /dev/null +++ b/test/e2e/app-dir/router-autoscroll.test.ts @@ -0,0 +1,216 @@ +import path from 'path' +import fs from 'fs-extra' +import webdriver from 'next-webdriver' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { waitFor } from 'next-test-utils' + +describe('router autoscrolling on navigation', () => { + let next: NextInstance + + const filesPath = path.join(__dirname, './router-autoscroll') + beforeAll(async () => { + next = await createNext({ + files: new FileRef(filesPath), + dependencies: { + react: 'latest', + 'react-dom': 'latest', + typescript: 'latest', + '@types/react': 'latest', + '@types/node': 'latest', + }, + }) + }) + afterAll(() => next.destroy()) + + const isReact17 = process.env.NEXT_TEST_REACT_VERSION === '^17' + if (isReact17) { + it('should skip tests for react 17', () => {}) + return + } + + /** These is no clear API so we just wait a really long time to avoid flakiness */ + const waitForScrollToComplete = () => waitFor(1000) + + type BrowserInterface = Awaited> + + const getTopScroll = async (browser: BrowserInterface) => + await browser.eval('document.documentElement.scrollTop') + + const getLeftScroll = async (browser: BrowserInterface) => + await browser.eval('document.documentElement.scrollLeft') + + const scrollTo = async ( + browser: BrowserInterface, + options: { x: number; y: number } + ) => { + await browser.eval(`window.scrollTo(${options.x}, ${options.y})`) + await waitForScrollToComplete() + } + + describe('vertical scroll', () => { + it('should scroll to top of document when navigating between to pages without layout', async () => { + const browser = await webdriver(next.url, '/0/0/100/10000/page1') + + await scrollTo(browser, { x: 0, y: 1000 }) + expect(await getTopScroll(browser)).toBe(1000) + + await browser.eval(`window.router.push("/0/0/100/10000/page2")`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(0) + + browser.quit() + }) + + it("should scroll to top of page when scrolling to phe top of the document wouldn't have the page in the viewport", async () => { + const browser = await webdriver(next.url, '/0/1000/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 1500 }) + expect(await getTopScroll(browser)).toBe(1500) + + await browser.eval(`window.router.push("/0/1000/100/1000/page2")`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(1000) + + browser.quit() + }) + + it("should scroll down to the navigated page when it's below viewort", async () => { + const browser = await webdriver(next.url, '/0/1000/100/1000/page1') + expect(await getTopScroll(browser)).toBe(0) + + await browser.eval(`window.router.push("/0/1000/100/1000/page2")`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(1000) + + browser.quit() + }) + + it('should not scroll when the top of the page is in the viewport', async () => { + const browser = await webdriver(next.url, '/10/1000/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 800 }) + expect(await getTopScroll(browser)).toBe(800) + + await browser.eval(`window.router.push("/10/1000/100/1000/page2")`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(800) + + browser.quit() + }) + + it('should not scroll to top of document if page in viewport', async () => { + const browser = await webdriver(next.url, '/10/100/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 50 }) + expect(await getTopScroll(browser)).toBe(50) + + await browser.eval(`window.router.push("/10/100/100/1000/page2")`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(50) + + browser.quit() + }) + + it('should scroll to top of document if possible while giving focus to page', async () => { + const browser = await webdriver(next.url, '/10/100/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 200 }) + expect(await getTopScroll(browser)).toBe(200) + + await browser.eval(`window.router.push("/10/100/100/1000/page2")`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(0) + + browser.quit() + }) + }) + + describe('horizontal scroll', () => { + it("should't scroll horizontally", async () => { + const browser = await webdriver(next.url, '/0/0/10000/10000/page1') + + await scrollTo(browser, { x: 1000, y: 1000 }) + expect(await getLeftScroll(browser)).toBe(1000) + expect(await getTopScroll(browser)).toBe(1000) + + await browser.eval(`window.router.push("/0/0/10000/10000/page2")`) + await waitForScrollToComplete() + + expect(await getLeftScroll(browser)).toBe(1000) + expect(await getTopScroll(browser)).toBe(0) + + browser.quit() + }) + }) + + describe('router.refresh()', () => { + it('should not scroll when called alone', async () => { + const browser = await webdriver(next.url, '/10/10000/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 12000 }) + expect(await getTopScroll(browser)).toBe(12000) + + await browser.eval(`window.router.refresh()`) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(12000) + + browser.quit() + }) + + // TODO fix next js to pass this + it.skip('should not stop router.push() from scrolling', async () => { + const browser = await webdriver(next.url, '/10/10000/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 12000 }) + expect(await getTopScroll(browser)).toBe(12000) + + await browser.eval(` + window.React.startTransition(() => { + window.router.push('/10/10000/100/1000/page2') + window.router.refresh() + }) + `) + await waitForScrollToComplete() + + expect(await getTopScroll(browser)).toBe(10000) + + browser.quit() + }) + + // Test hot reloading only in development + ;((global as any).isDev ? it : it.skip)( + 'should not scroll the page when we hot reload', + async () => { + const browser = await webdriver(next.url, '/10/10000/100/1000/page1') + + await scrollTo(browser, { x: 0, y: 12000 }) + expect(await getTopScroll(browser)).toBe(12000) + + const pagePath = + 'app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/page.tsx' + + await browser.eval(`window.router.refresh()`) + await next.patchFile( + pagePath, + fs.readFileSync(path.join(filesPath, pagePath)).toString() + + ` + \\\\ Add this meaningless comment to force refresh + ` + ) + await waitFor(1000) + + expect(await getTopScroll(browser)).toBe(12000) + + browser.quit() + } + ) + }) +}) diff --git a/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx b/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx new file mode 100644 index 00000000000000..569dcc349114fe --- /dev/null +++ b/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +export default function Layout({ + children, + params: { layoutPaddingHeight, layoutPaddingWidth, pageWidth, pageHeight }, +}: { + children: React.ReactNode + params: { + layoutPaddingWidth: string + layoutPaddingHeight: string + pageWidth: string + pageHeight: string + } +}) { + return ( +
+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/page.tsx b/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/page.tsx new file mode 100644 index 00000000000000..f82dabd6c9cad2 --- /dev/null +++ b/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/page.tsx @@ -0,0 +1,12 @@ +export default function Page() { + const randomColor = Math.floor(Math.random() * 16777215).toString(16) + return ( +
+ ) +} diff --git a/test/e2e/app-dir/router-autoscroll/app/layout.tsx b/test/e2e/app-dir/router-autoscroll/app/layout.tsx new file mode 100644 index 00000000000000..36125595f11659 --- /dev/null +++ b/test/e2e/app-dir/router-autoscroll/app/layout.tsx @@ -0,0 +1,39 @@ +'use client' + +import Link from 'next/link' +import React, { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function Layout({ children }: { children: React.ReactNode }) { + const router = useRouter() + + // We export these so that we can access them from tests + useEffect(() => { + // @ts-ignore + window.router = router + // @ts-ignore + window.React = React + }, [router]) + + return ( + + + +
+ +
+ {children} + + + ) +} diff --git a/test/e2e/app-dir/router-autoscroll/next.config.js b/test/e2e/app-dir/router-autoscroll/next.config.js new file mode 100644 index 00000000000000..e31d8c63a60d93 --- /dev/null +++ b/test/e2e/app-dir/router-autoscroll/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + strictMode: true, + experimental: { + appDir: true, + }, +} diff --git a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts index 1c9302892594e7..314e932e7bef32 100644 --- a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts @@ -103,9 +103,11 @@ describe('app dir - rsc basics', () => { // should have only 1 DOCTYPE expect(homeHTML).toMatch(/^') expect(homeHTML).toContain( - '' + '' ) + expect(homeHTML).toContain('component:index.server') expect(homeHTML).toContain('header:test-util') diff --git a/test/e2e/app-dir/rsc-errors/app/server-with-errors/error-file/error.js b/test/e2e/app-dir/rsc-errors/app/server-with-errors/error-file/error.js new file mode 100644 index 00000000000000..f144a53a1c9134 --- /dev/null +++ b/test/e2e/app-dir/rsc-errors/app/server-with-errors/error-file/error.js @@ -0,0 +1,3 @@ +'use client' + +export default function Error() {} diff --git a/test/e2e/app-dir/rsc-errors/app/server-with-errors/error-file/page.js b/test/e2e/app-dir/rsc-errors/app/server-with-errors/error-file/page.js new file mode 100644 index 00000000000000..a681aa7ce257cb --- /dev/null +++ b/test/e2e/app-dir/rsc-errors/app/server-with-errors/error-file/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello world

+} diff --git a/test/e2e/app-dir/rsc-errors/rsc-errors.test.ts b/test/e2e/app-dir/rsc-errors/rsc-errors.test.ts index 25c7290541a975..d1235952dae75a 100644 --- a/test/e2e/app-dir/rsc-errors/rsc-errors.test.ts +++ b/test/e2e/app-dir/rsc-errors/rsc-errors.test.ts @@ -151,6 +151,61 @@ if (!(globalThis as any).isNextDev) { `Element type is invalid. Received a promise that resolves to: undefined. Lazy element type must resolve to a class or function.` ) }) + + it('should throw an error when error file is a server component', async () => { + const browser = await next.browser('/server-with-errors/error-file') + + // Remove "use client" + await next.patchFile( + 'app/server-with-errors/error-file/error.js', + 'export default function Error() {}' + ) + expect(await hasRedbox(browser, true)).toBe(true) + expect(await getRedboxSource(browser)).toMatchInlineSnapshot(` + "./app/server-with-errors/error-file/error.js + + ./app/server-with-errors/error-file/error.js must be a Client Component. Add the \\"use client\\" directive the top of the file to resolve this issue. + + ,---- + 1 | export default function Error() {} + : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + \`---- + + Import path: + app/server-with-errors/error-file/error.js" + `) + + // Add "use client" + await next.patchFile( + 'app/server-with-errors/error-file/error.js', + '"use client"' + ) + expect(await hasRedbox(browser, false)).toBe(false) + + // Empty file + await next.patchFile('app/server-with-errors/error-file/error.js', '') + expect(await hasRedbox(browser, true)).toBe(true) + expect(await getRedboxSource(browser)).toMatchInlineSnapshot(` + "./app/server-with-errors/error-file/error.js + + ./app/server-with-errors/error-file/error.js must be a Client Component. Add the \\"use client\\" directive the top of the file to resolve this issue. + + ,---- + 1 | + : ^ + \`---- + + Import path: + app/server-with-errors/error-file/error.js" + `) + + // Fix + await next.patchFile( + 'app/server-with-errors/error-file/error.js', + '"use client"' + ) + expect(await hasRedbox(browser, false)).toBe(false) + }) } ) } diff --git a/test/integration/create-next-app/lib/specification.ts b/test/integration/create-next-app/lib/specification.ts index 4f3a58900d66bb..4001892270b84f 100644 --- a/test/integration/create-next-app/lib/specification.ts +++ b/test/integration/create-next-app/lib/specification.ts @@ -68,9 +68,9 @@ export const projectSpecification: ProjectSpecification = { deps: [], devDeps: [], files: [ - 'app/page.jsx', - 'app/head.jsx', - 'app/layout.jsx', + 'app/page.js', + 'app/head.js', + 'app/layout.js', 'pages/api/hello.js', 'jsconfig.json', ], diff --git a/test/lib/browsers/base.ts b/test/lib/browsers/base.ts index 87e31c85f57acf..4ea941bb9f9471 100644 --- a/test/lib/browsers/base.ts +++ b/test/lib/browsers/base.ts @@ -116,10 +116,18 @@ export class BrowserInterface implements PromiseLike { ): Promise {} async get(url: string): Promise {} - async getValue(): Promise {} - async getAttribute(name: string): Promise {} - async eval(snippet: string | Function): Promise {} - async evalAsync(snippet: string | Function): Promise {} + async getValue(): Promise { + return + } + async getAttribute(name: string): Promise { + return + } + async eval(snippet: string | Function): Promise { + return + } + async evalAsync(snippet: string | Function): Promise { + return + } async text(): Promise { return '' } diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts index 818da5147b053c..5613a5794b8081 100644 --- a/test/lib/browsers/playwright.ts +++ b/test/lib/browsers/playwright.ts @@ -277,8 +277,8 @@ export class Playwright extends BrowserInterface { }) as any } - async getAttribute(attr) { - return this.chain((el) => el.getAttribute(attr)) + async getAttribute(attr) { + return this.chain((el) => el.getAttribute(attr)) as T } hasElementByCssSelector(selector: string) { @@ -359,7 +359,7 @@ export class Playwright extends BrowserInterface { ) } - async evalAsync(snippet) { + async evalAsync(snippet) { if (typeof snippet === 'function') { snippet = snippet.toString() } @@ -377,7 +377,7 @@ export class Playwright extends BrowserInterface { })()` } - return page.evaluate(snippet).catch(() => null) + return page.evaluate(snippet).catch(() => null) } async log() { diff --git a/test/lib/browsers/selenium.ts b/test/lib/browsers/selenium.ts index e6e8c1aa001e2f..485e6967ad4afc 100644 --- a/test/lib/browsers/selenium.ts +++ b/test/lib/browsers/selenium.ts @@ -300,8 +300,8 @@ export class Selenium extends BrowserInterface { }) as any } - async getAttribute(attr) { - return this.chain((el) => el.getAttribute(attr)) + async getAttribute(attr) { + return this.chain((el) => el.getAttribute(attr)) as T } async hasElementByCssSelector(selector: string) { @@ -334,18 +334,18 @@ export class Selenium extends BrowserInterface { ) } - async eval(snippet) { + async eval(snippet) { if (typeof snippet === 'string' && !snippet.startsWith('return')) { snippet = `return ${snippet}` } - return browser.executeScript(snippet) + return browser.executeScript(snippet) } - async evalAsync(snippet) { + async evalAsync(snippet) { if (typeof snippet === 'string' && !snippet.startsWith('return')) { snippet = `return ${snippet}` } - return browser.executeAsyncScript(snippet) + return browser.executeAsyncScript(snippet) } async log() { diff --git a/turbo.json b/turbo.json index e5ddd1a816a543..87695181694d79 100644 --- a/turbo.json +++ b/turbo.json @@ -31,14 +31,13 @@ }, "typescript": {}, "test-pack": { - "dependsOn": ["^test-pack"], - "inputs": [ - "*", - "../../scripts/test-pack-package.mts", - "../../package.json" - ], + "dependsOn": ["^test-pack", "test-pack-global-deps"], "outputs": ["packed-*.tgz"], "env": ["NEXT_SWC_VERSION"] + }, + "test-pack-global-deps": { + "inputs": ["../../scripts/test-pack-package.mts", "../../package.json"], + "cache": false } } }