diff --git a/.github/workflows/autofix-docs.yml b/.github/workflows/autofix-docs.yml index 2ec7b6c82..edc59497e 100644 --- a/.github/workflows/autofix-docs.yml +++ b/.github/workflows/autofix-docs.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 06774c445..4af68cee5 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index dfcb36841..f3bc4aa39 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dc9c0055d..b2e4b57ea 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: fetch-depth: 0 - run: corepack enable diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 541a0b583..4a81648ec 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -25,10 +25,10 @@ jobs: restore-keys: cache-lychee- # check links with Lychee - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - name: Lychee link checker - uses: lycheeverse/lychee-action@054a8e8c7a88ada133165c6633a49825a32174e2 # for v1.8.0 + uses: lycheeverse/lychee-action@25a231001d1723960a301b7d4c82884dc7ef857d # for v1.8.0 with: # arguments with file types to check args: >- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f16e444a6..337bde873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: @@ -72,7 +72,7 @@ jobs: - build steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: @@ -83,7 +83,7 @@ jobs: run: pnpm install - name: Initialize CodeQL - uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/init@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6 with: languages: javascript queries: +security-and-quality @@ -95,7 +95,7 @@ jobs: path: packages - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/analyze@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6 with: category: "/language:javascript" @@ -111,7 +111,7 @@ jobs: module: ["bundler", "node"] steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: @@ -142,7 +142,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: @@ -166,7 +166,7 @@ jobs: needs: - build steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: @@ -199,8 +199,17 @@ jobs: context: ["async", "default"] manifest: ["manifest-on", "manifest-off"] version: ["v4", "v3"] + payload: ["json", "js"] node: [18] exclude: + - builder: "webpack" + payload: "js" + - manifest: "manifest-off" + payload: "js" + - context: "default" + payload: "js" + - os: windows-latest + payload: "js" - env: "dev" builder: "webpack" - manifest: "manifest-off" @@ -209,7 +218,7 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: @@ -236,9 +245,10 @@ jobs: TEST_MANIFEST: ${{ matrix.manifest }} TEST_CONTEXT: ${{ matrix.context }} TEST_V4: ${{ matrix.version == 'v4' }} - SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || runner.os == 'Windows' }} + TEST_PAYLOAD: ${{ matrix.payload }} + SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || matrix.payload == 'js' || runner.os == 'Windows' }} - - uses: codecov/codecov-action@5ecb98a3c6b747ed38dc09f787459979aebb39be # v4.3.1 + - uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # v4.4.1 if: github.event_name != 'push' && matrix.env == 'built' && matrix.builder == 'vite' && matrix.context == 'default' && matrix.os == 'ubuntu-latest' && matrix.manifest == 'manifest-on' with: token: ${{ secrets.CODECOV_TOKEN }} @@ -260,7 +270,7 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: fetch-depth: 0 - run: corepack enable @@ -299,7 +309,7 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: fetch-depth: 0 - run: corepack enable diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c2b9ca783..62daccd7f 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,6 +17,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - name: 'Dependency Review' uses: actions/dependency-review-action@0c155c5e8556a497adf53f2c18edabf945ed8e70 # v4.3.2 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 79535b8ef..f2bdb9b4f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - run: corepack enable - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: diff --git a/.github/workflows/introspect.yml b/.github/workflows/introspect.yml index 0eeced2da..06f799410 100644 --- a/.github/workflows/introspect.yml +++ b/.github/workflows/introspect.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 # From https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions - name: Check workflow files run: | diff --git a/.github/workflows/nuxt2-edge.yml b/.github/workflows/nuxt2-edge.yml index 242e42737..0b12d5c63 100644 --- a/.github/workflows/nuxt2-edge.yml +++ b/.github/workflows/nuxt2-edge.yml @@ -21,7 +21,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: ref: '2.x' fetch-depth: 0 # All history diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 851f92807..7c0bbc3a8 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -22,14 +22,14 @@ jobs: steps: - name: Ensure action is by maintainer - uses: octokit/request-action@21d174fc38ff59af9cf4d7e07347d29df6dbaa99 # v2.3.0 + uses: octokit/request-action@872c5c97b3c85c23516a572f02b31401ef82415d # v2.3.1 id: check_role with: route: GET /repos/nuxt/nuxt/collaborators/${{ github.event.comment.user.login }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: ref: refs/pull/${{ github.event.issue.number }}/merge fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf3f3e4f8..d975a363f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: fetch-depth: 0 - run: corepack enable diff --git a/.github/workflows/reproduire.yml b/.github/workflows/reproduire.yml index 6b36df019..b001dd9fc 100644 --- a/.github/workflows/reproduire.yml +++ b/.github/workflows/reproduire.yml @@ -10,7 +10,7 @@ jobs: reproduire: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - uses: Hebilicious/reproduire@4b686ae9cbb72dad60f001d278b6e3b2ce40a9ac # v0.0.9-mp with: label: needs reproduction diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 00fec63d0..1a0a7912d 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -32,12 +32,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3 with: results_file: results.sarif results_format: sarif @@ -68,7 +68,7 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/upload-sarif@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6 if: github.repository == 'nuxt/nuxt' && success() with: sarif_file: results.sarif diff --git a/README.md b/README.md index c054f7cff..9570d902e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ It provides a number of features that make it easy to build fast, SEO-friendly, - ❀️ [Contribute](#contribute) - 🏠 [Local Development](#local-development) - ⛰️ [Nuxt 2](#nuxt-2) +- πŸ›Ÿ [Professional Support](#professional-support) - πŸ”— [Follow us](#follow-us) - βš–οΈ [License](#license) @@ -104,6 +105,13 @@ Follow the docs to [Set Up Your Local Development Environment](https://nuxt.com/ You can find the code for Nuxt 2 on the [`2.x` branch](https://github.com/nuxt/nuxt/tree/2.x) and the documentation at [v2.nuxt.com](https://v2.nuxt.com). +If you expect to be using Nuxt 2 beyond the EOL (End of Life) date (June 30, 2024), and still need a maintained version that can satisfy security and browser compatibility requirements, make sure to check out [HeroDevs’ NES (Never-Ending Support) Nuxt 2](https://www.herodevs.com/support/nuxt-nes?utm_source=nuxt-github&utm_medium=nuxt-readme). + +## πŸ›Ÿ Professional Support + +- Technical audit & consulting: [Nuxt Experts](https://nuxt.com/enterprise/support) +- Custom development & more: [Nuxt Agencies Partners](https://nuxt.com/enterprise/agencies) + ## πŸ”— Follow us

diff --git a/docs/1.getting-started/11.testing.md b/docs/1.getting-started/11.testing.md index f9eea6d0c..9ae5b6264 100644 --- a/docs/1.getting-started/11.testing.md +++ b/docs/1.getting-started/11.testing.md @@ -10,6 +10,10 @@ If you are a module author, you can find more specific information in the [Modul Nuxt offers first-class support for end-to-end and unit testing of your Nuxt application via `@nuxt/test-utils`, a library of test utilities and configuration that currently powers the [tests we use on Nuxt itself](https://github.com/nuxt/nuxt/tree/main/test) and tests throughout the module ecosystem. +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=yGzwk9xi9gU" target="_blank"} +Watch a video from Alexander Lichter about getting started with the `@nuxt/test-utils`. +:: + ## Installation In order to allow you to manage your other testing dependencies, `@nuxt/test-utils` ships with various optional peer dependencies. For example: @@ -160,21 +164,32 @@ export default defineVitestConfig({ `mountSuspended` allows you to mount any Vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. For example: ```ts twoslash -import type { Component } from 'vue' import { it, expect } from 'vitest' -declare const SomeComponent: Component -declare const App: Component +import type { Component } from 'vue' +declare module '#components' { + export const SomeComponent: Component +} // ---cut--- // tests/components/SomeComponents.nuxt.spec.ts import { mountSuspended } from '@nuxt/test-utils/runtime' +import { SomeComponent } from '#components' it('can mount some component', async () => { const component = await mountSuspended(SomeComponent) expect(component.text()).toMatchInlineSnapshot( - 'This is an auto-imported component' + '"This is an auto-imported component"' ) }) +``` + +```ts twoslash +import { it, expect } from 'vitest' +// ---cut--- +// tests/components/SomeComponents.nuxt.spec.ts +import { mountSuspended } from '@nuxt/test-utils/runtime' +import App from '~/app.vue' + // tests/App.nuxt.spec.ts it('can also mount an app', async () => { const component = await mountSuspended(App, { route: '/test' }) @@ -199,13 +214,15 @@ The passed in component will be rendered inside a `

Examples: ```ts twoslash -import type { Component } from 'vue' import { it, expect } from 'vitest' -declare const SomeComponent: Component -declare const App: Component +import type { Component } from 'vue' +declare module '#components' { + export const SomeComponent: Component +} // ---cut--- // tests/components/SomeComponents.nuxt.spec.ts import { renderSuspended } from '@nuxt/test-utils/runtime' +import { SomeComponent } from '#components' import { screen } from '@testing-library/vue' it('can render some component', async () => { @@ -215,13 +232,11 @@ it('can render some component', async () => { ``` ```ts twoslash -import type { Component } from 'vue' import { it, expect } from 'vitest' -declare const SomeComponent: Component -declare const App: Component // ---cut--- // tests/App.nuxt.spec.ts import { renderSuspended } from '@nuxt/test-utils/runtime' +import App from '~/app.vue' it('can also render an app', async () => { const html = await renderSuspended(App, { route: '/test' }) @@ -358,7 +373,7 @@ registerEndpoint('/test/', { }) ``` -> **Note**: If your requests in a component go to external API, you can use `baseURL` and then make it empty using Nuxt Environment Config (`$test`) so all your requests will go to Nitro server. +> **Note**: If your requests in a component go to an external API, you can use `baseURL` and then make it empty using [Nuxt Environment Override Config](/docs/getting-started/configuration#environment-overrides) (`$test`) so all your requests will go to Nitro server. #### Conflict with End-To-End Testing diff --git a/docs/1.getting-started/12.upgrade.md b/docs/1.getting-started/12.upgrade.md index 8d6a28477..c8e6c66ef 100644 --- a/docs/1.getting-started/12.upgrade.md +++ b/docs/1.getting-started/12.upgrade.md @@ -5,11 +5,11 @@ navigation.icon: i-ph-arrow-circle-up-duotone --- -## Upgrading Nuxt 3 +## Upgrading Nuxt ### Latest release -To upgrade Nuxt 3 to the [latest release](https://github.com/nuxt/nuxt/releases), use the `nuxi upgrade` command. +To upgrade Nuxt to the [latest release](https://github.com/nuxt/nuxt/releases), use the `nuxi upgrade` command. ```bash [Terminal] npx nuxi upgrade @@ -17,7 +17,409 @@ npx nuxi upgrade ### Nightly Release Channel -To use the latest Nuxt 3 build and test features before their release, read about the [nightly release channel](/docs/guide/going-further/nightly-release-channel) guide. +To use the latest Nuxt build and test features before their release, read about the [nightly release channel](/docs/guide/going-further/nightly-release-channel) guide. + +## Testing Nuxt 4 + +Nuxt 4 is planned to be released **on or before June 14** (though obviously this is dependent on having enough time after Nitro's major release to be properly tested in the community, so be aware that this is not an exact date). + +Until then, it is possible to test many of Nuxt 4's breaking changes on the nightly release channel. + +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=r4wFKlcJK6c" target="_blank"} +Watch a video from Alexander Lichter showing how to opt in to Nuxt 4's breaking changes already. +:: + +### Opting in to Nuxt 4 + +First, opt in to the nightly release channel [following these steps](/docs/guide/going-further/nightly-release-channel#opting-in). + +Then you can set your `compatibilityVersion` to match Nuxt 4 behavior: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + future: { + compatibilityVersion: 4, + }, + // To re-enable _all_ Nuxt v3 behavior, set the following options: + // srcDir: '.', + // dir: { + // app: 'app' + // }, + // experimental: { + // sharedPrerenderData: false, + // compileTemplate: true, + // resetAsyncDataToUndefined: true, + // templateUtils: true, + // relativeWatchPaths: true, + // defaults: { + // useAsyncData: { + // deep: true + // } + // } + // }, + // unhead: { + // renderSSRHeadOptions: { + // omitLineBreaks: false + // } + // } +}) +``` + +When you set your `compatibilityVersion` to `4`, defaults throughout your Nuxt configuration will change to opt in to Nuxt v4 behavior, but you can granularly re-enable Nuxt v3 behavior when testing, following the commented out lines above. Please file issues if so, so that we can address them in Nuxt or in the ecosystem. + +### Migrating to Nuxt 4 + +Breaking or significant changes will be noted here along with migration steps for backward/forward compatibility. + +::alert +This section is subject to change until the final release, so please check back here regularly if you are testing Nuxt 4 using `compatibilityVersion: 4`. +:: + +#### New Directory Structure + +🚦 **Impact Level**: Significant + +Nuxt now defaults to a new directory structure, with backwards compatibility (so if Nuxt detects you are using the old structure, such as with a top-level `pages/` directory, this new structure will not apply). + +πŸ‘‰ [See full RFC](https://github.com/nuxt/nuxt/issues/26444) + +##### What Changed + +* the new Nuxt default `srcDir` is `app/` by default, and most things are resolved from there. +* `serverDir` now defaults to `/server` rather than `/server` +* `modules` and `public` are resolved relative to `` by default +* a new `dir.app` is added, which is the directory we look for `router.options.ts` and `spa-loading-template.html` - this defaults to `/` + +
+ +An example v4 folder structure. + +```sh +.output/ +.nuxt/ +app/ + assets/ + components/ + composables/ + layouts/ + middleware/ + pages/ + plugins/ + utils/ + app.config.ts + app.vue + router.options.ts +modules/ +node_modules/ +public/ +server/ + api/ + middleware/ + plugins/ + routes/ + utils/ +nuxt.config.ts +``` + +
+ +πŸ‘‰ For more details, see the [PR implementing this change](https://github.com/nuxt/nuxt/pull/27029). + +##### Reasons for Change + +1. **Performance** - placing all your code in the root of your repo causes issues with `.git/` and `node_modules/` folders being scanned/included by FS watchers which can significantly delay startup on non-Mac OSes. +1. **IDE type-safety** - `server/` and the rest of your app are running in two entirely different contexts with different global imports available, and making sure `server/` isn't _inside_ the same folder as the rest of your app is a big first step to ensuring you get good auto-completes in your IDE. + +##### Migration Steps + +1. Create a new directory called `app/`. +1. Move your `assets/`, `components/`, `composables/`, `layouts/`, `middleware/`, `pages/`, `plugins/` and `utils/` folders under it, as well as `app.vue`, `error.vue`, `app.config.ts`. If you have an `app/router-options.ts` or `app/spa-loading-template.html`, these paths remain the same. +1. Make sure your `nuxt.config.ts`, `modules/`, `public/` and `server/` folders remain outside the `app/` folder, in the root of your project. + +However, migration is _not required_. If you wish to keep your current folder structure, Nuxt should auto-detect it. (If it does not, please raise an issue.) You can also force a v3 folder structure with the following configuration: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + // This reverts the new srcDir default from `app` back to your root directory + srcDir: '.', + // This specifies the directory prefix for `app/router.options.ts` and `app/spa-loading-template.html` + dir: { + app: 'app' + } +}) +``` + +#### Shared Prerender Data + +🚦 **Impact Level**: Medium + +##### What Changed + +We enabled a previously experimental feature to share data from `useAsyncData` and `useFetch` calls, across different pages. See [original PR](https://github.com/nuxt/nuxt/pull/24894). + +##### Reasons for Change + +This feature automatically shares payload _data_ between pages that are prerendered. This can result in a significant performance improvement when prerendering sites that use `useAsyncData` or `useFetch` and fetch the same data in different pages. + +For example, if your site requires a `useFetch` call for every page (for example, to get navigation data for a menu, or site settings from a CMS), this data would only be fetched once when prerendering the first page that uses it, and then cached for use when prerendering other pages. + +##### Migration Steps + +Make sure that any unique key of your data is always resolvable to the same data. For example, if you are using `useAsyncData` to fetch data related to a particular page, you should provide a key that uniquely matches that data. (`useFetch` should do this automatically for you.) + +```ts [app/pages/test/[slug\\].vue] +// This would be unsafe in a dynamic page (e.g. `[slug].vue`) because the route slug makes a difference +// to the data fetched, but Nuxt can't know that because it's not reflected in the key. +const route = useRoute() +const { data } = await useAsyncData(async () => { + return await $fetch(`/api/my-page/${route.params.slug}`) +}) +// Instead, you should use a key that uniquely identifies the data fetched. +const { data } = await useAsyncData(route.params.slug, async () => { + return await $fetch(`/api/my-page/${route.params.slug}`) +}) +``` + +Alternatively, you can disable this feature with: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + experimental: { + sharedPrerenderData: false + } +}) +``` + +#### Default `data` and `error` values in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +`data` and `error` objects returned from `useAsyncData` will now default to `undefined`. + +##### Reasons for Change + +Previously `data` was initialized to `null` but reset in `clearNuxtData` to `undefined`. `error` was initialized to `null`. This change is to bring greater consistency. + +##### Migration Steps + +If you encounter any issues you can revert back to the previous behavior with: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + experimental: { + defaults: { + useAsyncData: { + value: 'null', + errorValue: 'null' + } + } + } +}) +``` + +Please report an issue if you are doing this, as we do not plan to keep this as configurable. + +#### Respect defaults when clearing `data` in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +If you provide a custom `default` value for `useAsyncData`, this will now be used when calling `clear` or `clearNuxtData` and it will be reset to its default value rather than simply unset. + +##### Reasons for Change + +Often users set an appropriately empty value, such as an empty array, to avoid the need to check for `null`/`undefined` when iterating over it. This should be respected when resetting/clearing the data. + +##### Migration Steps + +If you encounter any issues you can revert back to the previous behavior, for now, with: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + experimental: { + resetAsyncDataToUndefined: true, + } +}) +``` + +Please report an issue if you are doing so, as we do not plan to keep this as configurable. + +#### Shallow Data Reactivity in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +The `data` object returned from `useAsyncData`, `useFetch`, `useLazyAsyncData` and `useLazyFetch` is now a `shallowRef` rather than a `ref`. + +##### What Changed + +When new data is fetched, anything depending on `data` will still be reactive because the entire object is replaced. But if your code changes a property _within_ that data structure, this will not trigger any reactivity in your app. + +##### Reasons for Change + +This brings a **significant** performance improvement for deeply nested objects and arrays because Vue does not need to watch every single property/array for modification. In most cases, `data` should also be immutable. + +##### Migration Steps + +In most cases, no migration steps are required, but if you rely on the reactivity of the data object then you have two options: + +1. You can granularly opt in to deep reactivity on a per-composable basis: + ```diff + - const { data } = useFetch('/api/test') + + const { data } = useFetch('/api/test', { deep: true }) + ``` +1. You can change the default behavior on a project-wide basis (not recommended): + ```ts twoslash [nuxt.config.ts] + export default defineNuxtConfig({ + experimental: { + defaults: { + useAsyncData: { + deep: true + } + } + } + }) + ``` + +#### Absolute Watch Paths in `builder:watch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +The Nuxt `builder:watch` hook now emits a path which is absolute rather than relative to your project `srcDir`. + +##### Reasons for Change + +This allows us to support watching paths which are outside your `srcDir`, and offers better support for layers and other more complex patterns. + +##### Migration Steps + +We have already proactively migrated the public Nuxt modules which we are aware use this hook. See [issue #25339](https://github.com/nuxt/nuxt/issues/25339). + +However, if you are a module author using the `builder:watch` hook and wishing to remain backwards/forwards compatible, you can use the following code to ensure that your code works the same in both Nuxt v3 and Nuxt v4: + + ```diff ++ import { relative, resolve } from 'node:fs' + // ... + nuxt.hook('builder:watch', async (event, path) => { ++ path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)) + // ... + }) +``` + +#### Directory index scanning + +🚦 **Impact Level**: Medium + +##### What Changed + +Child folders in your `middleware/` folder are also scanned for `index` files and these are now also registered as middleware in your project. + +##### Reasons for Change + +Nuxt scans a number of folders automatically, including `middleware/` and `plugins/`. + +Child folders in your `plugins/` folder are scanned for `index` files and we wanted to make this behavior consistent between scanned directories. + +##### Migration Steps + +Probably no migration is necessary but if you wish to revert to previous behavior you can add a hook to filter out these middleware: + +```ts +export default defineNuxtConfig({ + hooks: { + 'app:resolve'(app) { + app.middleware = app.middleware.filter(mw => !/\/index\.[^/]+$/.test(mw.path)) + } + } +}) +``` + +#### Template Compilation Changes + +🚦 **Impact Level**: Minimal + +##### What Changed + +Previously, Nuxt used `lodash/template` to compile templates located on the file system using the `.ejs` file format/syntax. + +In addition, we provided some template utilities (`serialize`, `importName`, `importSources`) which could be used for code-generation within these templates, which are now being removed. + +##### Reasons for Change + +In Nuxt v3 we moved to a 'virtual' syntax with a `getContents()` function which is much more flexible and performant. + +In addition, `lodash/template` has had a succession of security issues. These do not really apply to Nuxt projects because it is being used at build-time, not runtime, and by trusted code. However, they still appear in security audits. Moreover, `lodash` is a hefty dependency and is unused by most projects. + +Finally, providing code serialization functions directly within Nuxt is not ideal. Instead, we maintain projects like [unjs/knitwork](http://github.com/unjs/knitwork) which can be dependencies of your project, and where security issues can be reported/resolved directly without requiring an upgrade of Nuxt itself. + +##### Migration Steps + +We have raised PRs to update modules using EJS syntax, but if you need to do this yourself, you have three backwards/forwards-compatible alternatives: + +* Moving your string interpolation logic directly into `getContents()`. +* Using a custom function to handle the replacement, such as in https://github.com/nuxt-modules/color-mode/pull/240. +* Continuing to use `lodash`, as a dependency of _your_ project rather than Nuxt: + +```diff ++ import { readFileSync } from 'node:fs' ++ import { template } from 'lodash-es' + // ... + addTemplate({ + fileName: 'appinsights-vue.js' + options: { /* some options */ }, +- src: resolver.resolve('./runtime/plugin.ejs'), ++ getContents({ options }) { ++ const contents = readFileSync(resolver.resolve('./runtime/plugin.ejs'), 'utf-8') ++ return template(contents)({ options }) ++ }, + }) +``` + +Finally, if you are using the template utilities (`serialize`, `importName`, `importSources`), you can replace them as follows with utilities from `knitwork`: + +```ts +import { genDynamicImport, genImport, genSafeVariableName } from 'knitwork' + +const serialize = (data: any) => JSON.stringify(data, null, 2).replace(/"{(.+)}"(?=,?$)/gm, r => JSON.parse(r).replace(/^{(.*)}$/, '$1')) + +const importSources = (sources: string | string[], { lazy = false } = {}) => { + return toArray(sources).map((src) => { + if (lazy) { + return `const ${genSafeVariableName(src)} = ${genDynamicImport(src, { comment: `webpackChunkName: ${JSON.stringify(src)}` })}` + } + return genImport(src, genSafeVariableName(src)) + }).join('\n') +} + +const importName = genSafeVariableName +``` + +#### Removal of Experimental Features + +🚦 **Impact Level**: Minimal + +##### What Changed + +Four experimental features are no longer configurable in Nuxt 4: + +* `treeshakeClientOnly` will be `true` (default since v3.0) +* `configSchema` will be `true` (default since v3.3) +* `polyfillVueUseHead` will be `false` (default since v3.4) +* `respectNoSSRHeader` will be `false` (default since v3.4) + +##### Reasons for Change + +These options have been set to their current values for some time and we do not have a reason to believe that they need to remain configurable. + +##### Migration Steps + +* `polyfillVueUseHead` is implementable in user-land with [this plugin](https://github.com/nuxt/nuxt/blob/f209158352b09d1986aa320e29ff36353b91c358/packages/nuxt/src/head/runtime/plugins/vueuse-head-polyfill.ts#L10-L11) + +* `respectNoSSRHeader`is implementable in user-land with [server middleware](https://github.com/nuxt/nuxt/blob/c660b39447f0d5b8790c0826092638d321cd6821/packages/nuxt/src/core/runtime/nitro/no-ssr.ts#L8-L9) ## Nuxt 2 vs Nuxt 3 @@ -26,7 +428,7 @@ In the table below, there is a quick comparison between 3 versions of Nuxt: Feature / Version | Nuxt 2 | Nuxt Bridge | Nuxt 3 -------------------------|-----------------|------------------|--------- Vue | 2 | 2 | 3 -Stability | 😊 Stable | 😌 Semi-stable | 😊 Stable +Stability | 😊 Stable | 😊 Stable | 😊 Stable Performance | 🏎 Fast | ✈️ Faster | πŸš€ Fastest Nitro Engine | ❌ | βœ… | βœ… ESM support | πŸŒ™ Partial | πŸ‘ Better | βœ… diff --git a/docs/1.getting-started/3.configuration.md b/docs/1.getting-started/3.configuration.md index c14e01f2f..49a0822ec 100644 --- a/docs/1.getting-started/3.configuration.md +++ b/docs/1.getting-started/3.configuration.md @@ -46,6 +46,10 @@ export default defineNuxtConfig({ }) ``` +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=DFZI2iVCrNc" target="_blank"} +Watch a video from Alexander Lichter about the env-aware `nuxt.config.ts`. +:: + ::note If you're authoring layers, you can also use the `$meta` key to provide metadata that you or the consumers of your layer might use. :: diff --git a/docs/1.getting-started/6.data-fetching.md b/docs/1.getting-started/6.data-fetching.md index 5a69126ea..32976b1f2 100644 --- a/docs/1.getting-started/6.data-fetching.md +++ b/docs/1.getting-started/6.data-fetching.md @@ -16,9 +16,9 @@ Both `useFetch` and `useAsyncData` share a common set of options and patterns th Before that, it's imperative to know why these composables exist in the first place. -## Why using specific composables? +## Why use specific composables for data fetching? -When using a framework like Nuxt that can perform calls and render pages on both client and server environments, some challenges must be addressed. This is why Nuxt provides composables to wrap your queries, instead of letting the developer rely on [`$fetch`](/docs/api/utils/dollarfetch) calls alone. +Nuxt is a framework which can run isomorphic (or universal) code in both server and client environments. If the [`$fetch` function](/docs/api/utils/dollarfetch) is used to perform data fetching in the setup function of a Vue component, this may cause data to be fetched twice, once on the server (to render the HTML) and once again on the client (when the HTML is hydrated). This is why Nuxt offers specific data fetching composables so data is fetched only once. ### Network calls duplication @@ -54,6 +54,10 @@ const { data: count } = await useFetch('/api/count') This composable is a wrapper around the [`useAsyncData`](/docs/api/composables/use-async-data) composable and `$fetch` utility. +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=njsGVmcWviY" target="_blank"} +Watch the video from Alexander Lichter to avoid using `useFetch` the wrong way! +:: + :read-more{to="/docs/api/composables/use-fetch"} :link-example{to="/docs/examples/features/data-fetching"} @@ -76,7 +80,7 @@ async function addTodo() { ``` ::warning -Beware that using only `$fetch` will not provide [network calls de-duplication and navigation prevention](#why-using-specific-composables). :br +Beware that using only `$fetch` will not provide [network calls de-duplication and navigation prevention](#why-use-specific-composables-for-data-fetching). :br It is recommended to use `$fetch` for client-side interactions (event based) or combined with [`useAsyncData`](#useasyncdata) when fetching the initial component data. :: @@ -93,6 +97,10 @@ The `useAsyncData` composable is responsible for wrapping async logic and return It's developer experience sugar for the most common use case. :: +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=0X-aOpSGabA" target="_blank"} +Watch a video from Alexander Lichter to dig deeper into the difference between `useFetch` and `useAsyncData`. +:: + There are some cases when using the [`useFetch`](/docs/api/composables/use-fetch) composable is not appropriate, for example when a CMS or a third-party provide their own query layer. In this case, you can use [`useAsyncData`](/docs/api/composables/use-async-data) to wrap your calls and still keep the benefits provided by the composable. ```vue [pages/users.vue] diff --git a/docs/1.getting-started/7.state-management.md b/docs/1.getting-started/7.state-management.md index d9edf3f16..4957fb4a1 100644 --- a/docs/1.getting-started/7.state-management.md +++ b/docs/1.getting-started/7.state-management.md @@ -8,6 +8,10 @@ Nuxt provides the [`useState`](/docs/api/composables/use-state) composable to cr [`useState`](/docs/api/composables/use-state) is an SSR-friendly [`ref`](https://vuejs.org/api/reactivity-core.html#ref) replacement. Its value will be preserved after server-side rendering (during client-side hydration) and shared across all components using a unique key. +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=mv0WcBABcIk" target="_blank"} +Watch a video from Alexander Lichter about why and when to use `useState()`. +:: + ::important Because the data inside [`useState`](/docs/api/composables/use-state) will be serialized to JSON, it is important that it does not contain anything that cannot be serialized, such as classes, functions or symbols. :: @@ -208,6 +212,10 @@ const color = useColor() // Same as useState('color') ``` +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=dZSNW07sO-A" target="_blank"} +Watch a video from Daniel Roe on how to deal with global state and SSR in Nuxt. +:: + ## Using third-party libraries Nuxt **used to rely** on the Vuex library to provide global state management. If you are migrating from Nuxt 2, please head to [the migration guide](/docs/migration/configuration#vuex). diff --git a/docs/1.getting-started/8.server.md b/docs/1.getting-started/8.server.md index 49e0f2146..2a67cc394 100644 --- a/docs/1.getting-started/8.server.md +++ b/docs/1.getting-started/8.server.md @@ -20,6 +20,10 @@ Using Nitro gives Nuxt superpowers: Nitro is internally using [h3](https://github.com/unjs/h3), a minimal H(TTP) framework built for high performance and portability. +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=DkvgJa-X31k" target="_blank"} +Watch a video from Alexander Lichter to understand the responsibilities of Nuxt and Nitro in your application. +:: + ## Server Endpoints & Middleware You can easily manage the server-only part of your Nuxt app, from API endpoints to middleware. diff --git a/docs/1.getting-started/9.layers.md b/docs/1.getting-started/9.layers.md index 0c2550794..7e05dadaf 100644 --- a/docs/1.getting-started/9.layers.md +++ b/docs/1.getting-started/9.layers.md @@ -51,7 +51,7 @@ Read more about layers in the **Layer Author Guide**. Watch a video from Learn Vue about Nuxt Layers. :: -::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=fr5yo3aVkfA&t=271s" target="_blank"} +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=fr5yo3aVkfA" target="_blank"} Watch a video from Alexander Lichter about Nuxt Layers. :: diff --git a/docs/2.guide/0.index.md b/docs/2.guide/0.index.md index b45915e12..a93d01cdb 100644 --- a/docs/2.guide/0.index.md +++ b/docs/2.guide/0.index.md @@ -16,4 +16,7 @@ surround: false ::card{icon="i-ph-star-duotone" title="Going Further" to="/docs/guide/going-further"} Master Nuxt with advanced concepts like experimental features, hooks, modules, and more. :: + ::card{icon="i-ph-book-open-duotone" title="Recipes" to="/docs/guide/recipes"} + Find solutions to common problems and learn how to implement them in your Nuxt project. + :: :: diff --git a/docs/2.guide/1.concepts/1.auto-imports.md b/docs/2.guide/1.concepts/1.auto-imports.md index 0fd8a3b63..a7e24cd84 100644 --- a/docs/2.guide/1.concepts/1.auto-imports.md +++ b/docs/2.guide/1.concepts/1.auto-imports.md @@ -60,6 +60,10 @@ That means that (with very few exceptions) you cannot use them outside a Nuxt pl If you get an error message like `Nuxt instance is unavailable` then it probably means you are calling a Nuxt composable in the wrong place in the Vue or Nuxt lifecycle. +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=ofuKRZLtOdY" target="_blank"} +Watch a video from Alexander Lichter about handling async code in composables and fixing `Nuxt instance is unavailable` in your app. +:: + ::read-more{to="/docs/guide/going-further/experimental-features#asynccontext" icon="i-ph-star-duotone"} Checkout the `asyncContext` experimental feature to use Nuxt composables in async functions. :: @@ -168,3 +172,7 @@ export default defineNuxtConfig({ } }) ``` + +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=FT2LQJ2NvVI" target="_blank"} +Watch a video from Alexander Lichter on how to easily set up custom auto imports. +:: diff --git a/docs/2.guide/1.concepts/8.typescript.md b/docs/2.guide/1.concepts/8.typescript.md index 3c57bd746..4e07f3563 100644 --- a/docs/2.guide/1.concepts/8.typescript.md +++ b/docs/2.guide/1.concepts/8.typescript.md @@ -65,6 +65,10 @@ This file contains the recommended basic TypeScript configuration for your proje [Read more about how to extend this configuration](/docs/guide/directory-structure/tsconfig). +::tip{icon="i-ph-video-duotone" to="https://youtu.be/umLI7SlPygY" target="_blank"} +Watch a video from Daniel Roe explaining built-in Nuxt aliases. +:: + ::note Nitro also [auto-generates types](/docs/guide/concepts/server-engine#typed-api-routes) for API routes. Plus, Nuxt also generates types for globally available components and [auto-imports from your composables](/docs/guide/directory-structure/composables), plus other core functionality. :: diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index 0685a3389..0cdf5cb4b 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -259,7 +259,7 @@ Watch Learn Vue video about Nuxt Server Components. :: ::tip{icon="i-ph-article-duotone" to="https://roe.dev/blog/nuxt-server-components" target="_blank"} -Read Daniel Roe's guide to Nuxt server components +Read Daniel Roe's guide to Nuxt Server Components. :: ### Standalone server components diff --git a/docs/2.guide/2.directory-structure/1.modules.md b/docs/2.guide/2.directory-structure/1.modules.md index 2664e5601..ce9de8f9f 100644 --- a/docs/2.guide/2.directory-structure/1.modules.md +++ b/docs/2.guide/2.directory-structure/1.modules.md @@ -46,7 +46,11 @@ export default defineEventHandler(() => { When starting Nuxt, the `hello` module will be registered and the `/api/hello` route will be available. -Local modules are registered in alphabetical order. You can change the order by adding a number to the front of each directory name: +Modules are executed in the following sequence: +- First, the modules defined in [`nuxt.config.ts`](/docs/api/nuxt-config#modules-1) are loaded. +- Then, modules found in the `modules/` directory are executed, and they load in alphabetical order. + +You can change the order of local module by adding a number to the front of each directory name: ```bash [Directory structure] modules/ diff --git a/docs/2.guide/2.directory-structure/1.plugins.md b/docs/2.guide/2.directory-structure/1.plugins.md index 3753847b5..b525b1f4c 100644 --- a/docs/2.guide/2.directory-structure/1.plugins.md +++ b/docs/2.guide/2.directory-structure/1.plugins.md @@ -76,6 +76,10 @@ export default defineNuxtPlugin({ }) ``` +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=2aXZyXB1QGQ" target="_blank"} +Watch a video from Alexander Lichter about the Object Syntax for Nuxt plugins. +:: + ::note If you are using the object-syntax, the properties may be statically analyzed in future to produce a more optimized build. So you should not define them at runtime. :br For example, setting `enforce: import.meta.server ? 'pre' : 'post'` would defeat any future optimization Nuxt is able to do for your plugins. diff --git a/docs/2.guide/2.directory-structure/2.env.md b/docs/2.guide/2.directory-structure/2.env.md index 660a8138d..0a39c625a 100644 --- a/docs/2.guide/2.directory-structure/2.env.md +++ b/docs/2.guide/2.directory-structure/2.env.md @@ -33,11 +33,25 @@ npx nuxi dev --dotenv .env.local When updating `.env` in development mode, the Nuxt instance is automatically restarted to apply new values to the `process.env`. -## Production Preview +## Production **After your server is built**, you are responsible for setting environment variables when you run the server. -Your `.env` file will not be read at this point. How you do this is different for every environment. +Your `.env` files will not be read at this point. How you do this is different for every environment. + +This design decision was made to ensure compatibility across various deployment environments, some of which may not have a traditional file system available, such as serverless platforms or edge networks like Cloudflare Workers. + +Since `.env` files are not used in production, you must explicitly set environment variables using the tools and methods provided by your hosting environment. Here are some common approaches: + +* You can pass the environment variables as arguments using the terminal: + + `$ DATABASE_HOST=mydatabaseconnectionstring node .output/server/index.mjs` + +* You can set environment variables in shell configuration files like `.bashrc` or `.profile`. + +* Many cloud service providers, such as Vercel, Netlify, and AWS, provide interfaces for setting environment variables via their dashboards, CLI tools or configuration files. + +## Production Preview For local production preview purpose, we recommend using [`nuxi preview`](/docs/api/commands/preview) since using this command, the `.env` file will be loaded into `process.env` for convenience. Note that this command requires dependencies to be installed in the package directory. diff --git a/docs/2.guide/3.going-further/1.experimental-features.md b/docs/2.guide/3.going-further/1.experimental-features.md index fd6ad8d44..691a2187c 100644 --- a/docs/2.guide/3.going-further/1.experimental-features.md +++ b/docs/2.guide/3.going-further/1.experimental-features.md @@ -306,6 +306,10 @@ Out of the box, this will enable typed usage of [`navigateTo`](/docs/api/utils/n You can even get typed params within a page by using `const route = useRoute('route-name')`. +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=SXk-L19gTZk" target="_blank"} +Watch a video from Daniel Roe explaining type-safe routing in Nuxt. +:: + ## watcher Set an alternative watcher that will be used as the watching service for Nuxt. @@ -340,6 +344,10 @@ export default defineNuxtConfig({ }) ``` +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=1jUupYHVvrU" target="_blank"} +Watch a video from Alexander Lichter about the experimental `sharedPrerenderData` setting. +:: + It is particularly important when enabling this feature to make sure that any unique key of your data is always resolvable to the same data. For example, if you are using `useAsyncData` to fetch data related to a particular page, you should provide a key that uniquely matches that data. (`useFetch` @@ -378,6 +386,16 @@ This option allows exposing some route metadata defined in `definePageMeta` at b This only works with static or strings/arrays rather than variables or conditional assignment. See [original issue](https://github.com/nuxt/nuxt/issues/24770) for more information and context. + + ## cookieStore Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. diff --git a/docs/2.guide/3.going-further/1.features.md b/docs/2.guide/3.going-further/1.features.md index 9439d708b..4ec31794a 100644 --- a/docs/2.guide/3.going-further/1.features.md +++ b/docs/2.guide/3.going-further/1.features.md @@ -40,7 +40,7 @@ There is also a `future` namespace for early opting-in to new features that will ### compatibilityVersion ::important -This configuration option is available in Nuxt v3.12+. +This configuration option is available in Nuxt v3.12+ or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). :: This enables early access to Nuxt features or flags. @@ -69,6 +69,11 @@ export default defineNuxtConfig({ deep: true } } + }, + unhead: { + renderSSRHeadOptions: { + omitLineBreaks: false + } } }) ``` diff --git a/docs/2.guide/3.going-further/10.runtime-config.md b/docs/2.guide/3.going-further/10.runtime-config.md index e84823704..9a1832ad5 100644 --- a/docs/2.guide/3.going-further/10.runtime-config.md +++ b/docs/2.guide/3.going-further/10.runtime-config.md @@ -61,6 +61,10 @@ Setting the default of `runtimeConfig` values to *differently named environment It is advised to use environment variables that match the structure of your `runtimeConfig` object. :: +::tip{icon="i-ph-video-duotone" to="https://youtu.be/_FYV5WfiWvs" target="_blank"} +Watch a video from Alexander Lichter showcasing the top mistake developers make using runtimeConfig. +:: + #### Example ```sh [.env] diff --git a/docs/2.guide/3.going-further/4.kit.md b/docs/2.guide/3.going-further/4.kit.md index 061073751..226762b89 100644 --- a/docs/2.guide/3.going-further/4.kit.md +++ b/docs/2.guide/3.going-further/4.kit.md @@ -15,6 +15,10 @@ Discover all Nuxt Kit utilities. You can install the latest Nuxt Kit by adding it to the `dependencies` section of your `package.json`. However, please consider always explicitly installing the `@nuxt/kit` package even if it is already installed by Nuxt. +::note +`@nuxt/kit` and `@nuxt/schema` are key dependencies for Nuxt. If you are installing it separately, make sure that the versions of `@nuxt/kit` and `@nuxt/schema` are equal to or greater than your `nuxt` version to avoid any unexpected behavior. +:: + ```json [package.json] { "dependencies": { diff --git a/docs/2.guide/4.recipes/3.custom-usefetch.md b/docs/2.guide/4.recipes/3.custom-usefetch.md new file mode 100644 index 000000000..e27af2712 --- /dev/null +++ b/docs/2.guide/4.recipes/3.custom-usefetch.md @@ -0,0 +1,105 @@ +--- +navigation.title: 'Custom useFetch' +title: Custom useFetch in Nuxt +description: How to create a custom fetcher for calling your external API in Nuxt 3. +--- + +When working with Nuxt, you might be making the frontend and fetching an external API, and you might want to set some default options for fetching from your API. + +The [`$fetch`](/docs/api/utils/dollarfetch) utility function (used by the [`useFetch`](/docs/api/composables/use-fetch) composable) is intentionally not globally configurable. This is important so that fetching behavior throughout your application remains consistent, and other integrations (like modules) can rely on the behavior of core utilities like `$fetch`. + +However, Nuxt provides a way to create a custom fetcher for your API (or multiple fetchers if you have multiple APIs to call). + +## Custom `$fetch` + +Let's create a custom `$fetch` instance with a [Nuxt plugin](/docs/guide/directory-structure/plugins). + +::note +`$fetch` is a configured instance of [ofetch](https://github.com/unjs/ofetch) which supports adding the base URL of your Nuxt server as well as direct function calls during SSR (avoiding HTTP roundtrips). +:: + +Let's pretend here that: +- The main API is https://api.nuxt.com +- We are storing the JWT token in a session with [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils) +- If the API responds with a `401` status code, we redirect the user to the `/login` page + +```ts [plugins/api.ts] +export default defineNuxtPlugin(() => { + const { session } = useUserSession() + + const api = $fetch.create({ + baseURL: 'https://api.nuxt.com', + onRequest({ request, options, error }) { + if (session.value?.token) { + const headers = options.headers ||= {} + if (Array.isArray(headers)) { + headers.push(['Authorization', `Bearer ${session.value?.token}`]) + } else if (headers instanceof Headers) { + headers.set('Authorization', `Bearer ${session.value?.token}`) + } else { + headers.Authorization = `Bearer ${session.value?.token}` + } + } + }, + async onResponseError({ response }) { + if (response.status === 401) { + await navigateTo('/login') + } + } + }) + + // Expose to useNuxtApp().$api + return { + provide: { + api + } + } +}) +``` + +With this Nuxt plugin, `$api` is exposed from `useNuxtApp()` to make API calls directly from the Vue components: + +```vue [app.vue] + +``` + +::callout +Wrapping with [`useAsyncData`](/docs/api/composables/use-async-data) **avoid double data fetching when doing server-side rendering** (server & client on hydration). +:: + +## Custom `useFetch` + +Now that `$api` has the logic we want, let's create a `useAPI` composable to replace the usage of `useAsyncData` + `$api`: + +```ts [composables/useAPI.ts] +import type { UseFetchOptions } from 'nuxt/app' + +export function useAPI( + url: string | (() => string), + options: Omit, 'default'> & { default: () => T | Ref }, +) { + return useFetch(url, { + ...options, + $fetch: useNuxtApp().$api + }) +} +``` + +Let's use the new composable and have a nice and clean component: + +```vue [app.vue] + +``` + +::callout{icon="i-simple-icons-youtube" color="red" to="https://www.youtube.com/watch?v=jXH8Tr-exhI"} +Watch a video about custom `$fetch` and Repository Pattern in Nuxt. +:: + +::note +We are currently discussing to find a cleaner way to let you create a custom fetcher, see https://github.com/nuxt/nuxt/issues/14736. +:: diff --git a/docs/3.api/1.components/12.nuxt-route-announcer.md b/docs/3.api/1.components/12.nuxt-route-announcer.md index 671a1d3df..4f6e90862 100644 --- a/docs/3.api/1.components/12.nuxt-route-announcer.md +++ b/docs/3.api/1.components/12.nuxt-route-announcer.md @@ -11,7 +11,7 @@ links: --- ::important -This component will be available in Nuxt v3.12. +This component will be available in Nuxt v3.12 or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). :: ## Usage diff --git a/docs/3.api/1.components/4.nuxt-link.md b/docs/3.api/1.components/4.nuxt-link.md index 5e5480714..b623d1ce5 100644 --- a/docs/3.api/1.components/4.nuxt-link.md +++ b/docs/3.api/1.components/4.nuxt-link.md @@ -25,6 +25,22 @@ In this example, we use `` component to link to another page of the ap ``` +### Passing Params to Dynamic Routes + +In this example, we pass the `id` param to link to the route `~/pages/posts/[id].vue`. + +```vue [pages/index.vue] + +``` + +::tip +Check out the Pages panel in Nuxt DevTools to see the route name and the params it might take. +:: + ### Handling 404s When using `` for `/public` directory files or when pointing to a different app on the same domain, you should use the `external` prop. diff --git a/docs/3.api/1.components/5.nuxt-loading-indicator.md b/docs/3.api/1.components/5.nuxt-loading-indicator.md index e22b58242..ad2f6936c 100644 --- a/docs/3.api/1.components/5.nuxt-loading-indicator.md +++ b/docs/3.api/1.components/5.nuxt-loading-indicator.md @@ -30,6 +30,7 @@ You can pass custom HTML or components through the loading indicator's default s ## Props - `color`: The color of the loading bar. It can be set to `false` to turn off explicit color styling. +- `errorColor`: The color of the loading bar when `error` is set to `true`. - `height`: Height of the loading bar, in pixels (default `3`). - `duration`: Duration of the loading bar, in milliseconds (default `2000`). - `throttle`: Throttle the appearing and hiding, in milliseconds (default `200`). diff --git a/docs/3.api/2.composables/on-prehydrate.md b/docs/3.api/2.composables/on-prehydrate.md new file mode 100644 index 000000000..79073123a --- /dev/null +++ b/docs/3.api/2.composables/on-prehydrate.md @@ -0,0 +1,61 @@ +--- +title: "onPrehydrate" +description: "Use onPrehydrate to run a callback on the client immediately before +Nuxt hydrates the page." +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/ssr.ts + size: xs +--- + +::important +This composable will be available in Nuxt v3.12+ or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). +:: + +`onPrehydrate` is a composable lifecycle hook that allows you to run a callback on the client immediately before +Nuxt hydrates the page. + +::note +This is an advanced utility and should be used with care. For example, [`nuxt-time`](https://github.com/danielroe/nuxt-time/pull/251) and [`@nuxtjs/color-mode`](https://github.com/nuxt-modules/color-mode/blob/main/src/script.js) manipulate the DOM to avoid hydration mismatches. +:: + +## Usage + +`onPrehydrate` can be called directly in the setup function of a Vue component (for example, in ` + + +``` diff --git a/docs/3.api/2.composables/use-cookie.md b/docs/3.api/2.composables/use-cookie.md index 4319f7f05..804d3e278 100644 --- a/docs/3.api/2.composables/use-cookie.md +++ b/docs/3.api/2.composables/use-cookie.md @@ -59,13 +59,10 @@ Use these options to set the expiration of the cookie. The given number will be converted to an integer by rounding down. By default, no maximum age is set. `expires`: Specifies the `Date` object to be the value for the [`Expires` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.1). -By default, no expiration is set. Most clients will consider this a "non-persistent cookie" and -will delete it on a condition like exiting a web browser application. +By default, no expiration is set. Most clients will consider this a "non-persistent cookie" and will delete it on a condition like exiting a web browser application. ::note -The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states that if both `expires` and -`maxAge` is set, then `maxAge` takes precedence, but not all clients may obey this, -so if both are set, they should point to the same date and time! +The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states that if both `expires` and `maxAge` is set, then `maxAge` takes precedence, but not all clients may obey this, so if both are set, they should point to the same date and time! :: ::note @@ -74,22 +71,29 @@ If neither of `expires` and `maxAge` is set, the cookie will be session-only and ### `httpOnly` -Specifies the `boolean` value for the [`HttpOnly` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.6). When truthy, -the `HttpOnly` attribute is set; otherwise it is not. By default, the `HttpOnly` attribute is not set. +Specifies the `boolean` value for the [`HttpOnly` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.6). When truthy, the `HttpOnly` attribute is set; otherwise it is not. By default, the `HttpOnly` attribute is not set. ::warning -Be careful when setting this to `true`, as compliant clients will not allow client-side -JavaScript to see the cookie in `document.cookie`. +Be careful when setting this to `true`, as compliant clients will not allow client-side JavaScript to see the cookie in `document.cookie`. :: ### `secure` -Specifies the `boolean` value for the [`Secure` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.5). When truthy, -the `Secure` attribute is set; otherwise it is not. By default, the `Secure` attribute is not set. +Specifies the `boolean` value for the [`Secure` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.5). When truthy, the `Secure` attribute is set; otherwise it is not. By default, the `Secure` attribute is not set. ::warning -Be careful when setting this to `true`, as compliant clients will not send the cookie back to -the server in the future if the browser does not have an HTTPS connection. This can lead to hydration errors. +Be careful when setting this to `true`, as compliant clients will not send the cookie back to the server in the future if the browser does not have an HTTPS connection. This can lead to hydration errors. +:: + +### `partitioned` + +Specifies the `boolean` value for the [`Partitioned` `Set-Cookie`](https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1) attribute. When truthy, the `Partitioned` attribute is set, otherwise it is not. By default, the `Partitioned` attribute is not set. + +::note +This is an attribute that has not yet been fully standardized, and may change in the future. +This also means many clients may ignore this attribute until they understand it. + +More information can be found in the [proposal](https://github.com/privacycg/CHIPS). :: ### `domain` @@ -114,23 +118,18 @@ More information about the different enforcement levels can be found in [the spe ### `encode` -Specifies a function that will be used to encode a cookie's value. Since the value of a cookie -has a limited character set (and must be a simple string), this function can be used to encode -a value into a string suited for a cookie's value. +Specifies a function that will be used to encode a cookie's value. Since the value of a cookie has a limited character set (and must be a simple string), this function can be used to encode a value into a string suited for a cookie's value. The default encoder is the `JSON.stringify` + `encodeURIComponent`. ### `decode` -Specifies a function that will be used to decode a cookie's value. Since the value of a cookie -has a limited character set (and must be a simple string), this function can be used to decode -a previously encoded cookie value into a JavaScript string or other object. +Specifies a function that will be used to decode a cookie's value. Since the value of a cookie has a limited character set (and must be a simple string), this function can be used to decode a previously encoded cookie value into a JavaScript string or other object. The default decoder is `decodeURIComponent` + [destr](https://github.com/unjs/destr). ::note -If an error is thrown from this function, the original, non-decoded cookie value will -be returned as the cookie's value. +If an error is thrown from this function, the original, non-decoded cookie value will be returned as the cookie's value. :: ### `default` diff --git a/docs/3.api/2.composables/use-fetch.md b/docs/3.api/2.composables/use-fetch.md index e0149ce11..84a73d0fc 100644 --- a/docs/3.api/2.composables/use-fetch.md +++ b/docs/3.api/2.composables/use-fetch.md @@ -66,6 +66,10 @@ const { data, pending, error, refresh } = await useFetch('/api/auth/login', { `useFetch` is a reserved function name transformed by the compiler, so you should not name your own function `useFetch`. :: +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=njsGVmcWviY" target="_blank"} +Watch the video from Alexander Lichter to avoid using `useFetch` the wrong way! +:: + :link-example{to="/docs/examples/advanced/use-custom-fetch-composable"} :read-more{to="/docs/getting-started/data-fetching"} @@ -83,6 +87,8 @@ const { data, pending, error, refresh } = await useFetch('/api/auth/login', { - `headers`: Request headers. - `baseURL`: Base URL for the request. - `timeout`: Milliseconds to automatically abort request + - `cache`: Handles cache control according to [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/fetch#cache) + - You can pass boolean to disable the cache or you can pass one of the following values: `default`, `no-store`, `reload`, `no-cache`, `force-cache`, and `only-if-cached`. ::note All fetch options can be given a `computed` or `ref` value. These will be watched and new requests made automatically with any new values if they are updated. diff --git a/docs/3.api/2.composables/use-loading-indicator.md b/docs/3.api/2.composables/use-loading-indicator.md index 2e4a5bb97..357c260bb 100644 --- a/docs/3.api/2.composables/use-loading-indicator.md +++ b/docs/3.api/2.composables/use-loading-indicator.md @@ -26,6 +26,11 @@ It hooks into [`page:loading:start`](/docs/api/advanced/hooks#app-hooks-runtime) - **type**: `Ref` - **description**: The loading state +### `error` + +- **type**: `Ref` +- **description**: The error state + ### `progress` - **type**: `Ref` @@ -39,7 +44,7 @@ Set `isLoading` to true and start to increase the `progress` value. ### `finish()` -Set the `progress` value to `100`, stop all timers and intervals then reset the loading state `500` ms later. `finish` accepts a `{ force: true }` option to skip the interval before the state is reset. +Set the `progress` value to `100`, stop all timers and intervals then reset the loading state `500` ms later. `finish` accepts a `{ force: true }` option to skip the interval before the state is reset, and `{ error: true }` to change the loading bar color and set the error property to true. ### `clear()` diff --git a/docs/3.api/2.composables/use-nuxt-app.md b/docs/3.api/2.composables/use-nuxt-app.md index 29a920cdb..860cb89c2 100644 --- a/docs/3.api/2.composables/use-nuxt-app.md +++ b/docs/3.api/2.composables/use-nuxt-app.md @@ -18,6 +18,14 @@ const nuxtApp = useNuxtApp() If runtime context is unavailable in your scope, `useNuxtApp` will throw an exception when called. You can use [`tryUseNuxtApp`](#tryusenuxtapp) instead for composables that do not require `nuxtApp`, or to simply check if context is available or not without an exception. + + ## Methods ### `provide (name, value)` @@ -130,6 +138,10 @@ Nuxt exposes the following properties through `ssrContext`: Since [Nuxt v3.4](https://nuxt.com/blog/v3-4#payload-enhancements), it is possible to define your own reducer/reviver for types that are not supported by Nuxt. + ::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=8w6ffRBs8a4" target="_blank"} + Watch a video from Alexander Lichter about serializing payloads, especially with regards to classes. + :: + In the example below, we define a reducer (or a serializer) and a reviver (or deserializer) for the [Luxon](https://moment.github.io/luxon/#/) DateTime class, using a payload plugin. ```ts [plugins/date-time-payload.ts] @@ -278,3 +290,7 @@ export function useStandType() { } } ``` + + diff --git a/docs/3.api/2.composables/use-route-announcer.md b/docs/3.api/2.composables/use-route-announcer.md index f81059e20..c3272631f 100644 --- a/docs/3.api/2.composables/use-route-announcer.md +++ b/docs/3.api/2.composables/use-route-announcer.md @@ -11,7 +11,7 @@ links: --- ::important -This composable will be available in Nuxt v3.12. +This composable will be available in Nuxt v3.12 or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). :: ## Description diff --git a/docs/3.api/2.composables/use-state.md b/docs/3.api/2.composables/use-state.md index dccccae07..513bd407c 100644 --- a/docs/3.api/2.composables/use-state.md +++ b/docs/3.api/2.composables/use-state.md @@ -25,6 +25,10 @@ Because the data inside `useState` will be serialized to JSON, it is important t `useState` is a reserved function name transformed by the compiler, so you should not name your own function `useState`. :: +::tip{icon="i-ph-video-duotone" to="https://www.youtube.com/watch?v=mv0WcBABcIk" target="_blank"} +Watch a video from Alexander Lichter about why and when to use `useState()`. +:: + ## Using `shallowRef` If you don't need your state to be deeply reactive, you can combine `useState` with [`shallowRef`](https://vuejs.org/api/reactivity-advanced.html#shallowref). This can improve performance when your state contains large objects and arrays. diff --git a/docs/3.api/4.commands/init.md b/docs/3.api/4.commands/init.md index 0514e24e9..52c739cf9 100644 --- a/docs/3.api/4.commands/init.md +++ b/docs/3.api/4.commands/init.md @@ -18,11 +18,17 @@ The `init` command initializes a fresh Nuxt project using [unjs/giget](https://g Option | Default | Description -------------------------|-----------------|------------------ +`--cwd` | | Current working directory +`--log-level` | | Log level `--template, -t` | `v3` | Specify template name or git repository to use as a template. Format is `gh:org/name` to use a custom github template. -`--force` | `false` | Force clone to any existing directory. -`--offline` | `false` | Do not attempt to download from github and only use local cache. -`--prefer-offline` | `false` | Try local cache first to download templates. -`--shell` | `false` | Open shell in cloned directory (experimental). +`--force, -f` | `false` | Force clone to any existing directory. +`--offline` | `false` | Force offline mode (do not attempt to download template from GitHub and only use local cache). +`--prefer-offline` | `false` | Prefer offline mode (try local cache first to download templates). +`--no-install` | `false` | Skip installing dependencies. +`--git-init` | `false` | Initialize git repository. +`--shell` | `false` | Start shell after installation in project directory (experimental). +`--package-manager` | `npm` | Package manager choice (npm, pnpm, yarn, bun). +`--dir` | | Project directory. ## Environment variables diff --git a/docs/3.api/5.kit/10.runtime-config.md b/docs/3.api/5.kit/10.runtime-config.md new file mode 100644 index 000000000..49f534870 --- /dev/null +++ b/docs/3.api/5.kit/10.runtime-config.md @@ -0,0 +1,27 @@ +--- +title: Runtime Config +description: Nuxt Kit provides a set of utilities to help you access and modify Nuxt runtime configuration. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/kit/src/runtime-config.ts + size: xs +--- + +## `useRuntimeConfig` + +At build-time, it is possible to access the resolved Nuxt [runtime config](/docs/guide/going-further/runtime-config). + +### Type + +```ts +function useRuntimeConfig (): Record +``` + +## `updateRuntimeConfig` + +It is also possible to update runtime configuration. This will be merged with the existing runtime configuration, and if Nitro has already been initialized it will trigger an HMR event to reload the Nitro runtime config. + +```ts +function updateRuntimeConfig (config: Record): void | Promise +``` diff --git a/docs/3.api/5.kit/3.compatibility.md b/docs/3.api/5.kit/3.compatibility.md index 20eebebd3..a253f7e84 100644 --- a/docs/3.api/5.kit/3.compatibility.md +++ b/docs/3.api/5.kit/3.compatibility.md @@ -25,6 +25,12 @@ async function checkNuxtCompatibility( interface NuxtCompatibility { nuxt?: string; bridge?: boolean; + builder?: { + // Set `false` if your module is not compatible with a builder + // or a semver-compatible string version constraint + vite?: false | string; + webpack?: false | string; + }; } interface NuxtCompatibilityIssue { diff --git a/docs/5.community/4.contribution.md b/docs/5.community/4.contribution.md index 3702d845a..a601e4a51 100644 --- a/docs/5.community/4.contribution.md +++ b/docs/5.community/4.contribution.md @@ -32,6 +32,10 @@ We'll do our best to follow our [internal issue decision making flowchart](https ### Send a Pull Request +::Tip +On windows, you need to clone the repository with `git clone -c core.symlinks=true https://github.com/nuxt/nuxt.git` to make symlinks work. +:: + We always welcome pull requests! ❀️ #### Before You Start diff --git a/docs/5.community/6.roadmap.md b/docs/5.community/6.roadmap.md index 6337f81fe..f485be077 100644 --- a/docs/5.community/6.roadmap.md +++ b/docs/5.community/6.roadmap.md @@ -38,12 +38,12 @@ Translations | - | [nuxt/translations#4](https://github.com/nuxt/tra In addition to the Nuxt framework, there are modules that are vital for the ecosystem. Their status will be updated below. -Module | Status | Nuxt Support | Repository | Description ----------------|---------------------|--------------|------------|------------------- -Scripts | April 2024 | 3.x | `nuxt/scripts` to be announced | Easy 3rd party script management. [nuxt/nuxt#22016](https://github.com/nuxt/nuxt/discussions/22016) -A11y | Planned | 3.x | `nuxt/a11y` to be announced | Accessibility hinting and utilities [nuxt/nuxt#23255](https://github.com/nuxt/nuxt/issues/23255) -Auth | Planned | 3.x | `nuxt/auth` to be announced | Nuxt 3 support is planned after session support -Hints | Planned | 3.x | `nuxt/hints` to be announced | Guidance and suggestions for enhancing development practices +Module | Status | Nuxt Support | Repository | Description +------------------------------------|---------------------|--------------|------------|------------------- +[Scripts](https://scripts.nuxt.com) | Public Preview | 3.x | [nuxt/scripts](https://github.com/nuxt/scripts) | Easy 3rd party script management. +A11y | Planned | 3.x | `nuxt/a11y` to be announced | Accessibility hinting and utilities [nuxt/nuxt#23255](https://github.com/nuxt/nuxt/issues/23255) +Auth | Planned | 3.x | `nuxt/auth` to be announced | Nuxt 3 support is planned after session support. +Hints | Planned | 3.x | `nuxt/hints` to be announced | Guidance and suggestions for enhancing development practices. ## Release Cycle @@ -64,9 +64,9 @@ Each active version has its own nightly releases which are generated automatical Release | | Initial release | End Of Life | Docs ----------------------------------------|---------------------------------------------------------------------------------------------------|-----------------|--------------|------- **4.x** (scheduled) | | 2024 Q2 | |   -**3.x** (stable) | Nuxt latest 3.x version | 2022-11-16 | TBA | [nuxt.com](/docs) -**2.x** (maintenance) | Nuxt 2.x version | 2018-09-21 | 2024-06-30 | [v2.nuxt.com](https://v2.nuxt.com/docs) -**1.x** (unsupported) | Nuxt 1.x version | 2018-01-08 | 2019-09-21 |   +**3.x** (stable) | Nuxt latest 3.x version | 2022-11-16 | TBA | [nuxt.com](/docs) +**2.x** (maintenance) | Nuxt 2.x version | 2018-09-21 | 2024-06-30 | [v2.nuxt.com](https://v2.nuxt.com/docs) +**1.x** (unsupported) | Nuxt 1.x version | 2018-01-08 | 2019-09-21 |   ### Support Status diff --git a/docs/5.community/7.changelog.md b/docs/5.community/7.changelog.md index 3016d4f68..5daf444f3 100644 --- a/docs/5.community/7.changelog.md +++ b/docs/5.community/7.changelog.md @@ -68,6 +68,16 @@ navigation.icon: i-ph-notification-duotone ::card --- icon: i-simple-icons-github + title: nuxt/scripts + to: https://github.com/nuxt/scripts/tags + target: _blank + ui.icon.base: text-black dark:text-white + --- + Nuxt Scripts releases. (Public Preview) + :: + ::card + --- + icon: i-simple-icons-github title: nuxt/ui to: https://github.com/nuxt/ui/releases target: _blank diff --git a/package.json b/package.json index 7cd4f8bc4..6dd9b42cd 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "license": "MIT", "type": "module", "scripts": { - "build": "pnpm --filter @nuxt/ui-templates prepack && pnpm --filter './packages/[^u]**' prepack", + "build": "pnpm --filter './packages/**' prepack", "build:stub": "pnpm dev:prepare", "cleanup": "rimraf 'packages/**/node_modules' 'playground/node_modules' 'node_modules'", "dev": "pnpm play", - "dev:prepare": "pnpm --filter @nuxt/ui-templates prepack && pnpm --filter './packages/[^u]**' prepack --stub", + "dev:prepare": "pnpm --filter './packages/**' prepack --stub", "lint": "eslint . --cache", "lint:fix": "eslint . --cache --fix", "lint:docs": "markdownlint ./docs && case-police 'docs/**/*.md' *.md", @@ -20,6 +20,7 @@ "lint:knip": "pnpx knip", "play": "nuxi dev playground", "play:build": "nuxi build playground", + "play:generate": "nuxi generate playground", "play:preview": "nuxi preview playground", "test": "pnpm test:fixtures && pnpm test:fixtures:dev && pnpm test:fixtures:webpack && pnpm test:unit && pnpm test:runtime && pnpm test:types && pnpm typecheck", "test:prepare": "jiti ./test/prepare.ts", @@ -28,7 +29,7 @@ "test:fixtures:webpack": "TEST_BUILDER=webpack pnpm test:fixtures", "test:runtime": "vitest -c vitest.nuxt.config.ts", "test:types": "pnpm --filter './test/fixtures/**' test:types", - "test:unit": "JITI_CACHE=0 vitest run packages/", + "test:unit": "vitest run packages/", "typecheck": "tsc --noEmit", "typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs" }, @@ -40,59 +41,59 @@ "@nuxt/webpack-builder": "workspace:*", "magic-string": "^0.30.10", "nuxt": "workspace:*", - "rollup": "^4.17.2", - "vite": "5.2.11", - "vue": "3.4.26" + "rollup": "^4.18.0", + "vite": "5.2.12", + "vue": "3.4.27" }, "devDependencies": { - "@eslint/js": "9.1.1", - "@nuxt/eslint-config": "0.3.10", + "@eslint/js": "9.3.0", + "@nuxt/eslint-config": "0.3.13", "@nuxt/kit": "workspace:*", - "@nuxt/test-utils": "3.12.1", + "@nuxt/test-utils": "3.13.1", "@nuxt/webpack-builder": "workspace:*", - "@testing-library/vue": "8.0.3", + "@testing-library/vue": "8.1.0", "@types/eslint__js": "8.42.3", "@types/fs-extra": "11.0.4", - "@types/node": "20.12.8", + "@types/node": "20.12.13", "@types/semver": "7.5.8", - "@vitest/coverage-v8": "1.5.3", - "@vue/test-utils": "2.4.5", + "@vitest/coverage-v8": "1.6.0", + "@vue/test-utils": "2.4.6", "case-police": "0.6.1", "changelogen": "0.5.5", "consola": "3.2.3", "devalue": "5.0.0", - "eslint": "9.1.1", + "eslint": "9.3.0", "eslint-plugin-no-only-tests": "3.1.0", "eslint-plugin-perfectionist": "2.10.0", "eslint-typegen": "0.2.4", - "execa": "8.0.1", + "execa": "9.1.0", "fs-extra": "11.2.0", "globby": "14.0.1", "h3": "1.11.1", - "happy-dom": "14.7.1", + "happy-dom": "14.12.0", "jiti": "1.21.0", - "markdownlint-cli": "0.40.0", + "markdownlint-cli": "0.41.0", "nitropack": "2.9.6", "nuxi": "3.11.1", "nuxt": "workspace:*", "nuxt-content-twoslash": "0.0.10", "ofetch": "1.3.4", "pathe": "1.1.2", - "playwright-core": "1.43.1", - "rimraf": "5.0.5", - "semver": "7.6.0", + "playwright-core": "1.44.1", + "rimraf": "5.0.7", + "semver": "7.6.2", "std-env": "3.7.0", "typescript": "5.4.5", "ufo": "1.5.3", - "vitest": "1.5.3", + "vitest": "1.6.0", "vitest-environment-nuxt": "1.0.0", - "vue": "3.4.26", + "vue": "3.4.27", "vue-router": "4.3.2", - "vue-tsc": "2.0.16" + "vue-tsc": "2.0.19" }, - "packageManager": "pnpm@9.0.6", + "packageManager": "pnpm@9.1.3", "engines": { - "node": "^14.18.0 || >=16.10.0" + "node": "^16.10.0 || >=18.0.0" }, "version": "" } diff --git a/packages/kit/package.json b/packages/kit/package.json index 56e353bfc..62d145b02 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -30,19 +30,21 @@ "c12": "^1.10.0", "consola": "^3.2.3", "defu": "^6.1.4", + "destr": "^2.0.3", "globby": "^14.0.1", "hash-sum": "^2.0.0", "ignore": "^5.3.1", "jiti": "^1.21.0", + "klona": "^2.0.6", "knitwork": "^1.1.0", "mlly": "^1.7.0", "pathe": "^1.1.2", - "pkg-types": "^1.1.0", + "pkg-types": "^1.1.1", "scule": "^1.3.0", - "semver": "^7.6.0", + "semver": "^7.6.2", "ufo": "^1.5.3", "unctx": "^2.3.1", - "unimport": "^3.7.1", + "unimport": "^3.7.2", "untyped": "^1.4.2" }, "devDependencies": { @@ -52,8 +54,8 @@ "lodash-es": "4.17.21", "nitropack": "2.9.6", "unbuild": "latest", - "vite": "5.2.11", - "vitest": "1.5.3", + "vite": "5.2.12", + "vitest": "1.6.0", "webpack": "5.91.0" }, "engines": { diff --git a/packages/kit/src/compatibility.ts b/packages/kit/src/compatibility.ts index 77f33180c..b2720f05c 100644 --- a/packages/kit/src/compatibility.ts +++ b/packages/kit/src/compatibility.ts @@ -1,9 +1,15 @@ import satisfies from 'semver/functions/satisfies.js' // npm/node-semver#381 +import { readPackageJSON } from 'pkg-types' import type { Nuxt, NuxtCompatibility, NuxtCompatibilityIssues } from '@nuxt/schema' import { useNuxt } from './context' export function normalizeSemanticVersion (version: string) { - return version.replace(/-[0-9]+\.[0-9a-f]+/, '') // Remove edge prefix + return version.replace(/-\d+\.[0-9a-f]+/, '') // Remove edge prefix +} + +const builderMap = { + '@nuxt/vite-builder': 'vite', + '@nuxt/webpack-builder': 'webpack', } /** @@ -40,6 +46,28 @@ export async function checkNuxtCompatibility (constraints: NuxtCompatibility, nu } } + // Builder compatibility check + if (constraints.builder && typeof nuxt.options.builder === 'string') { + const currentBuilder = builderMap[nuxt.options.builder] || nuxt.options.builder + if (currentBuilder in constraints.builder) { + const constraint = constraints.builder[currentBuilder]! + if (constraint === false) { + issues.push({ + name: 'builder', + message: `Not compatible with \`${nuxt.options.builder}\`.`, + }) + } else { + const builderVersion = await readPackageJSON(nuxt.options.builder, { url: nuxt.options.modulesDir }).then(r => r.version).catch(() => undefined) + if (builderVersion && !satisfies(normalizeSemanticVersion(builderVersion), constraint, { includePrerelease: true })) { + issues.push({ + name: 'builder', + message: `Not compatible with \`${builderVersion}\` of \`${currentBuilder}\`. This module requires \`${constraint}\`.`, + }) + } + } + } + } + // Allow extending compatibility checks await nuxt.callHook('kit:compatibility', constraints, issues) diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index fb2350660..405de8606 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -10,6 +10,7 @@ export * from './loader/nuxt' // Utils export * from './imports' +export { updateRuntimeConfig, useRuntimeConfig } from './runtime-config' export * from './build' export * from './compatibility' export * from './components' diff --git a/packages/kit/src/internal/template.ts b/packages/kit/src/internal/template.ts index 9349f704d..5b40a2a21 100644 --- a/packages/kit/src/internal/template.ts +++ b/packages/kit/src/internal/template.ts @@ -27,7 +27,7 @@ export async function compileTemplate (template: NuxtTemplate, ctx: any) { } /** @deprecated */ -const serialize = (data: any) => JSON.stringify(data, null, 2).replace(/"{(.+)}"(?=,?$)/gm, r => JSON.parse(r).replace(/^{(.*)}$/, '$1')) +const serialize = (data: any) => JSON.stringify(data, null, 2).replace(/"\{(.+)\}"(?=,?$)/gm, r => JSON.parse(r).replace(/^\{(.*)\}$/, '$1')) /** @deprecated */ const importSources = (sources: string | string[], { lazy = false } = {}) => { diff --git a/packages/kit/src/loader/config.ts b/packages/kit/src/loader/config.ts index 3c42525c4..fab118bf6 100644 --- a/packages/kit/src/loader/config.ts +++ b/packages/kit/src/loader/config.ts @@ -4,6 +4,8 @@ import type { ConfigLayer, ConfigLayerMeta, LoadConfigOptions } from 'c12' import { loadConfig } from 'c12' import type { NuxtConfig, NuxtOptions } from '@nuxt/schema' import { NuxtConfigSchema } from '@nuxt/schema' +import { globby } from 'globby' +import defu from 'defu' export interface LoadNuxtConfigOptions extends LoadConfigOptions {} @@ -11,12 +13,19 @@ const layerSchemaKeys = ['future', 'srcDir', 'rootDir', 'dir'] const layerSchema = Object.fromEntries(Object.entries(NuxtConfigSchema).filter(([key]) => layerSchemaKeys.includes(key))) export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise { + // Automatically detect and import layers from `~~/layers/` directory + opts.overrides = defu(opts.overrides, { + _extends: await globby('layers/*', { + onlyDirectories: true, + cwd: opts.cwd || process.cwd(), + }), + }); (globalThis as any).defineNuxtConfig = (c: any) => c const result = await loadConfig({ name: 'nuxt', configFile: 'nuxt.config', rcFile: '.nuxtrc', - extend: { extendKey: ['theme', 'extends'] }, + extend: { extendKey: ['theme', 'extends', '_extends'] }, dotenv: true, globalRc: true, ...opts, diff --git a/packages/kit/src/module/define.ts b/packages/kit/src/module/define.ts index 3327ca4ad..ff2a56d2d 100644 --- a/packages/kit/src/module/define.ts +++ b/packages/kit/src/module/define.ts @@ -73,8 +73,8 @@ export function defineNuxtModule (definition: Mo const key = `nuxt:module:${uniqueKey || (Math.round(Math.random() * 10000))}` const mark = performance.mark(key) const res = await module.setup?.call(null as any, _options, nuxt) ?? {} - const perf = performance.measure(key, mark?.name) // TODO: remove when Node 14 reaches EOL - const setupTime = perf ? Math.round((perf.duration * 100)) / 100 : 0 // TODO: remove when Node 14 reaches EOL + const perf = performance.measure(key, mark.name) + const setupTime = Math.round((perf.duration * 100)) / 100 // Measure setup time if (setupTime > 5000 && uniqueKey !== '@nuxt/telemetry') { diff --git a/packages/kit/src/plugin.ts b/packages/kit/src/plugin.ts index 5421c3027..8905c1127 100644 --- a/packages/kit/src/plugin.ts +++ b/packages/kit/src/plugin.ts @@ -3,7 +3,6 @@ import type { NuxtPlugin, NuxtPluginTemplate } from '@nuxt/schema' import { useNuxt } from './context' import { addTemplate } from './template' import { resolveAlias } from './resolve' -import { logger } from './logger' /** * Normalize a nuxt plugin object @@ -20,12 +19,6 @@ export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin { throw new Error('Invalid plugin. src option is required: ' + JSON.stringify(plugin)) } - // TODO: only scan top-level files #18418 - const nonTopLevelPlugin = plugin.src.match(/\/plugins\/[^/]+\/index\.[^/]+$/i) - if (nonTopLevelPlugin && nonTopLevelPlugin.length > 0 && !useNuxt().options.plugins.find(i => (typeof i === 'string' ? i : i.src).endsWith(nonTopLevelPlugin[0]))) { - logger.warn(`[deprecation] You are using a plugin that is within a subfolder of your plugins directory without adding it to your config explicitly. You can move it to the top-level plugins directory, or include the file '~${nonTopLevelPlugin[0]}' in your plugins config (https://nuxt.com/docs/api/nuxt-config#plugins-1) to remove this warning.`) - } - // Normalize full path to plugin plugin.src = normalize(resolveAlias(plugin.src)) diff --git a/packages/kit/src/runtime-config.ts b/packages/kit/src/runtime-config.ts new file mode 100644 index 000000000..034c59fa1 --- /dev/null +++ b/packages/kit/src/runtime-config.ts @@ -0,0 +1,103 @@ +import process from 'node:process' +import destr from 'destr' +import { snakeCase } from 'scule' +import { klona } from 'klona' + +import defu from 'defu' +import { useNuxt } from './context' +import { useNitro } from './nitro' + +/** + * Access 'resolved' Nuxt runtime configuration, with values updated from environment. + * + * This mirrors the runtime behavior of Nitro. + */ +export function useRuntimeConfig () { + const nuxt = useNuxt() + return applyEnv(klona(nuxt.options.nitro.runtimeConfig!), { + prefix: 'NITRO_', + altPrefix: 'NUXT_', + envExpansion: nuxt.options.nitro.experimental?.envExpansion ?? !!process.env.NITRO_ENV_EXPANSION, + }) +} + +/** + * Update Nuxt runtime configuration. + */ +export function updateRuntimeConfig (runtimeConfig: Record) { + const nuxt = useNuxt() + Object.assign(nuxt.options.nitro.runtimeConfig as Record, defu(runtimeConfig, nuxt.options.nitro.runtimeConfig)) + + try { + return useNitro().updateConfig({ runtimeConfig }) + } catch { + // Nitro is not yet initialised - we can safely ignore this error + } +} + +/** + * https://github.com/unjs/nitro/blob/main/src/runtime/utils.env.ts. +* + * These utils will be replaced by util exposed from nitropack. See https://github.com/unjs/nitro/pull/2404 + * for more context and future plans.) + * + * @internal + */ + +type EnvOptions = { + prefix?: string + altPrefix?: string + envExpansion?: boolean +} + +function getEnv (key: string, opts: EnvOptions, env = process.env) { + const envKey = snakeCase(key).toUpperCase() + return destr( + env[opts.prefix + envKey] ?? env[opts.altPrefix + envKey], + ) +} + +function _isObject (input: unknown) { + return typeof input === 'object' && !Array.isArray(input) +} + +function applyEnv ( + obj: Record, + opts: EnvOptions, + parentKey = '', +) { + for (const key in obj) { + const subKey = parentKey ? `${parentKey}_${key}` : key + const envValue = getEnv(subKey, opts) + if (_isObject(obj[key])) { + // Same as before + if (_isObject(envValue)) { + obj[key] = { ...(obj[key] as any), ...(envValue as any) } + applyEnv(obj[key], opts, subKey) + } else if (envValue === undefined) { + // If envValue is undefined + // Then proceed to nested properties + applyEnv(obj[key], opts, subKey) + } else { + // If envValue is a primitive other than undefined + // Then set objValue and ignore the nested properties + obj[key] = envValue ?? obj[key] + } + } else { + obj[key] = envValue ?? obj[key] + } + // Experimental env expansion + if (opts.envExpansion && typeof obj[key] === 'string') { + obj[key] = _expandFromEnv(obj[key]) + } + } + return obj +} + +const envExpandRx = /\{\{(.*?)\}\}/g + +function _expandFromEnv (value: string, env: Record = process.env) { + return value.replace(envExpandRx, (match, key) => { + return env[key] || match + }) +} diff --git a/packages/kit/src/template.ts b/packages/kit/src/template.ts index 9290e62fd..4aa9ec147 100644 --- a/packages/kit/src/template.ts +++ b/packages/kit/src/template.ts @@ -202,7 +202,7 @@ export async function _generateTypes (nuxt: Nuxt) { } else { const path = stats?.isFile() // remove extension - ? relativePath.replace(/(?<=\w)\.\w+$/g, '') + ? relativePath.replace(/\b\.\w+$/g, '') // non-existent file probably shouldn't be resolved : aliases[alias] @@ -230,7 +230,7 @@ export async function _generateTypes (nuxt: Nuxt) { tsConfig.compilerOptions!.paths[alias] = await Promise.all(paths.map(async (path: string) => { if (!isAbsolute(path)) { return path } const stats = await fsp.stat(path).catch(() => null /* file does not exist */) - return relativeWithDot(nuxt.options.buildDir, stats?.isFile() ? path.replace(/(?<=\w)\.\w+$/g, '') /* remove extension */ : path) + return relativeWithDot(nuxt.options.buildDir, stats?.isFile() ? path.replace(/\b\.\w+$/g, '') /* remove extension */ : path) })) } diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index d7c999398..403b6c4d8 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -60,15 +60,15 @@ }, "dependencies": { "@nuxt/devalue": "^2.0.2", - "@nuxt/devtools": "^1.2.0", + "@nuxt/devtools": "^1.3.2", "@nuxt/kit": "workspace:*", "@nuxt/schema": "workspace:*", "@nuxt/telemetry": "^2.5.4", "@nuxt/vite-builder": "workspace:*", - "@unhead/dom": "^1.9.8", - "@unhead/ssr": "^1.9.8", - "@unhead/vue": "^1.9.8", - "@vue/shared": "^3.4.26", + "@unhead/dom": "^1.9.11", + "@unhead/ssr": "^1.9.11", + "@unhead/vue": "^1.9.11", + "@vue/shared": "^3.4.27", "acorn": "8.11.3", "c12": "^1.10.0", "chokidar": "^3.6.0", @@ -76,7 +76,7 @@ "defu": "^6.1.4", "destr": "^2.0.3", "devalue": "^5.0.0", - "esbuild": "^0.20.2", + "esbuild": "^0.21.4", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "fs-extra": "^11.2.0", @@ -96,9 +96,10 @@ "ohash": "^1.1.3", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", - "pkg-types": "^1.1.0", + "pkg-types": "^1.1.1", "radix3": "^1.1.2", "scule": "^1.3.0", + "semver": "^7.6.2", "std-env": "^3.7.0", "strip-literal": "^2.1.0", "ufo": "^1.5.3", @@ -106,25 +107,25 @@ "uncrypto": "^0.1.3", "unctx": "^2.3.1", "unenv": "^1.9.0", - "unimport": "^3.7.1", + "unimport": "^3.7.2", "unplugin": "^1.10.1", "unplugin-vue-router": "^0.7.0", "unstorage": "^1.10.2", "untyped": "^1.4.2", - "vue": "^3.4.26", - "vue-bundle-renderer": "^2.0.0", + "vue": "^3.4.27", + "vue-bundle-renderer": "^2.1.0", "vue-devtools-stub": "^0.1.0", "vue-router": "^4.3.2" }, "devDependencies": { - "@nuxt/ui-templates": "1.3.3", + "@nuxt/ui-templates": "1.3.4", "@parcel/watcher": "2.4.1", "@types/estree": "1.0.5", "@types/fs-extra": "11.0.4", "@vitejs/plugin-vue": "5.0.4", "unbuild": "latest", - "vite": "5.2.11", - "vitest": "1.5.3" + "vite": "5.2.12", + "vitest": "1.6.0" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 9b86f2229..95270055d 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -8,7 +8,7 @@ import type { } from 'vue' import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent } from 'vue' import type { RouteLocation, RouteLocationRaw, Router, RouterLink, RouterLinkProps, useLink } from '#vue-router' -import { hasProtocol, joinURL, parseQuery, parseURL, withTrailingSlash, withoutTrailingSlash } from 'ufo' +import { hasProtocol, joinURL, parseQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo' import { preloadRouteComponents } from '../composables/preload' import { onNuxtReady } from '../composables/ready' import { navigateTo, useRouter } from '../composables/router' @@ -393,7 +393,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { get route () { if (!href.value) { return undefined } - const url = parseURL(href.value) + const url = new URL(href.value, import.meta.client ? window.location.href : 'http://localhost') return { path: url.pathname, fullPath: url.pathname, diff --git a/packages/nuxt/src/app/components/nuxt-loading-indicator.ts b/packages/nuxt/src/app/components/nuxt-loading-indicator.ts index 2b51dd658..a0273a842 100644 --- a/packages/nuxt/src/app/components/nuxt-loading-indicator.ts +++ b/packages/nuxt/src/app/components/nuxt-loading-indicator.ts @@ -20,20 +20,24 @@ export default defineComponent({ type: [String, Boolean], default: 'repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)', }, + errorColor: { + type: String, + default: 'repeating-linear-gradient(to right,#f87171 0%,#ef4444 100%)', + }, estimatedProgress: { type: Function as unknown as () => (duration: number, elapsed: number) => number, required: false, }, }, setup (props, { slots, expose }) { - const { progress, isLoading, start, finish, clear } = useLoadingIndicator({ + const { progress, isLoading, error, start, finish, clear } = useLoadingIndicator({ duration: props.duration, throttle: props.throttle, estimatedProgress: props.estimatedProgress, }) expose({ - progress, isLoading, start, finish, clear, + progress, isLoading, error, start, finish, clear, }) return () => h('div', { @@ -47,7 +51,7 @@ export default defineComponent({ width: 'auto', height: `${props.height}px`, opacity: isLoading.value ? 1 : 0, - background: props.color || undefined, + background: error.value ? props.errorColor : props.color || undefined, backgroundSize: `${(100 / progress.value) * 100}% auto`, transform: `scaleX(${progress.value}%)`, transformOrigin: 'left', diff --git a/packages/nuxt/src/app/components/nuxt-root.vue b/packages/nuxt/src/app/components/nuxt-root.vue index 9c8c91516..eefe5fee7 100644 --- a/packages/nuxt/src/app/components/nuxt-root.vue +++ b/packages/nuxt/src/app/components/nuxt-root.vue @@ -1,7 +1,8 @@ - `, 'hello.server.vue', false, true) + `, 'hello.server.vue', true) expect(result).toMatchInlineSnapshot(` " " diff --git a/packages/nuxt/test/load-nuxt.test.ts b/packages/nuxt/test/load-nuxt.test.ts index d6207329c..7efadc7e4 100644 --- a/packages/nuxt/test/load-nuxt.test.ts +++ b/packages/nuxt/test/load-nuxt.test.ts @@ -1,11 +1,32 @@ import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { normalize } from 'pathe' import { withoutTrailingSlash } from 'ufo' +import { readPackageJSON } from 'pkg-types' +import { inc } from 'semver' import { loadNuxt } from '../src' +import { version } from '../package.json' const repoRoot = withoutTrailingSlash(normalize(fileURLToPath(new URL('../../../', import.meta.url)))) +vi.stubGlobal('console', { + ...console, + error: vi.fn(console.error), + warn: vi.fn(console.warn), +}) + +vi.mock('pkg-types', async (og) => { + const originalPkgTypes = (await og()) + return { + ...originalPkgTypes, + readPackageJSON: vi.fn(originalPkgTypes.readPackageJSON), + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + describe('loadNuxt', () => { it('respects hook overrides', async () => { let hookRan = false @@ -24,3 +45,44 @@ describe('loadNuxt', () => { expect(hookRan).toBe(true) }) }) + +describe('dependency mismatch', () => { + it('expect mismatched dependency to log a warning', async () => { + vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({ + version: '3.0.0', + })) + + const nuxt = await loadNuxt({ + cwd: repoRoot, + }) + + expect(console.warn).toHaveBeenCalledWith(`[nuxt] Expected \`@nuxt/kit\` to be at least \`${version}\` but got \`3.0.0\`. This might lead to unexpected behavior. Check your package.json or refresh your lockfile.`) + expect(console.warn).toHaveBeenCalledWith(`[nuxt] Expected \`@nuxt/schema\` to be at least \`${version}\` but got \`3.0.0\`. This might lead to unexpected behavior. Check your package.json or refresh your lockfile.`) + + vi.mocked(readPackageJSON).mockRestore() + await nuxt.close() + }) + it.each([ + { + name: 'nuxt version is lower', + depVersion: inc(version, 'minor'), + }, + { + name: 'version matches', + depVersion: version, + }, + ])('expect no warning when $name.', async ({ depVersion }) => { + vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({ + depVersion, + })) + + const nuxt = await loadNuxt({ + cwd: repoRoot, + }) + + expect(console.warn).not.toHaveBeenCalled() + + await nuxt.close() + vi.mocked(readPackageJSON).mockRestore() + }) +}) diff --git a/packages/nuxt/test/route-injection.test.ts b/packages/nuxt/test/route-injection.test.ts new file mode 100644 index 000000000..d2b7b6f69 --- /dev/null +++ b/packages/nuxt/test/route-injection.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' +import type { Plugin } from 'vite' +import type { Nuxt } from '@nuxt/schema' + +import { RouteInjectionPlugin } from '../src/pages/plugins/route-injection' + +describe('route-injection:transform', () => { + const injectionPlugin = RouteInjectionPlugin({ options: { sourcemap: { client: false, server: false } } } as Nuxt).raw({}, { framework: 'rollup' }) as Plugin + + const transform = async (source: string) => { + const result = await (injectionPlugin.transform! as Function).call({ error: null, warn: null } as any, source, 'test.vue') + const code: string = typeof result === 'string' ? result : result?.code + let depth = 0 + return code.split('\n').map((l) => { + l = l.trim() + if (l.match(/^[}\]]/)) { depth-- } + const res = ''.padStart(depth * 2, ' ') + l + if (l.match(/[{[]$/)) { depth++ } + return res + }).join('\n') + } + + it('should correctly inject route in template', async () => { + const sfc = `` + const res = compileTemplate({ + filename: 'test.vue', + id: 'test.vue', + source: sfc, + }) + const transformResult = await transform(res.code) + expect(transformResult).toMatchInlineSnapshot(` + "import { PageRouteSymbol as __nuxt_route_symbol } from '#app/components/injections'; + import { toDisplayString as _toDisplayString, createTextVNode as _createTextVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + + export function render(_ctx, _cache) { + return (_openBlock(), _createElementBlock("template", null, [ + _createTextVNode(_toDisplayString((_ctx._.provides[__nuxt_route_symbol] || _ctx.$route).path), 1 /* TEXT */) + ])) + }" + `) + }) + + it('should correctly inject route in options api', async () => { + const sfc = ` + + + ` + + const res = compileScript(parse(sfc).descriptor, { id: 'test.vue' }) + const transformResult = await transform(res.content) + expect(transformResult).toMatchInlineSnapshot(` + "import { PageRouteSymbol as __nuxt_route_symbol } from '#app/components/injections'; + + export default { + computed: { + thing () { + return (this._.provides[__nuxt_route_symbol] || this.$route).path + } + } + } + " + `) + }) +}) diff --git a/packages/nuxt/test/treeshake-client.test.ts b/packages/nuxt/test/treeshake-client.test.ts index 9306bec38..e0b076d95 100644 --- a/packages/nuxt/test/treeshake-client.test.ts +++ b/packages/nuxt/test/treeshake-client.test.ts @@ -182,7 +182,7 @@ describe('treeshake client only in ssr', () => { expect(treeshaken).not.toContain('ssrRenderComponent(_unref(HelloWorld') expect(treeshaken).toContain('ssrRenderComponent(_unref(Glob') } - expect(treeshaken.replace(/data-v-[\d\w]{8}/g, 'data-v-one-hash').replace(/scoped=[\d\w]{8}/g, 'scoped=one-hash')).toMatchSnapshot() + expect(treeshaken.replace(/data-v-\w{8}/g, 'data-v-one-hash').replace(/scoped=\w{8}/g, 'scoped=one-hash')).toMatchSnapshot() }) } diff --git a/packages/schema/package.json b/packages/schema/package.json index bdffa786b..df4e1fa7a 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -35,16 +35,16 @@ }, "devDependencies": { "@nuxt/telemetry": "2.5.4", - "@nuxt/ui-templates": "1.3.3", + "@nuxt/ui-templates": "1.3.4", "@types/file-loader": "5.0.4", "@types/pug": "2.0.10", "@types/sass-loader": "8.0.8", - "@unhead/schema": "1.9.8", + "@unhead/schema": "1.9.11", "@vitejs/plugin-vue": "5.0.4", "@vitejs/plugin-vue-jsx": "3.1.0", - "@vue/compiler-core": "3.4.26", - "@vue/compiler-sfc": "3.4.26", - "@vue/language-core": "2.0.16", + "@vue/compiler-core": "3.4.27", + "@vue/compiler-sfc": "3.4.27", + "@vue/language-core": "2.0.19", "c12": "1.10.0", "esbuild-loader": "4.1.0", "h3": "1.11.1", @@ -54,9 +54,9 @@ "unbuild": "latest", "unctx": "2.3.1", "unenv": "1.9.0", - "vite": "5.2.11", - "vue": "3.4.26", - "vue-bundle-renderer": "2.0.0", + "vite": "5.2.12", + "vue": "3.4.27", + "vue-bundle-renderer": "2.1.0", "vue-loader": "17.4.2", "vue-router": "4.3.2", "webpack": "5.91.0", @@ -67,11 +67,12 @@ "defu": "^6.1.4", "hookable": "^5.5.3", "pathe": "^1.1.2", - "pkg-types": "^1.1.0", + "pkg-types": "^1.1.1", "scule": "^1.3.0", "std-env": "^3.7.0", "ufo": "^1.5.3", - "unimport": "^3.7.1", + "unimport": "^3.7.2", + "uncrypto": "^0.1.3", "untyped": "^1.4.2" }, "engines": { diff --git a/packages/schema/src/config/app.ts b/packages/schema/src/config/app.ts index 340a507bc..ad4026c15 100644 --- a/packages/schema/src/config/app.ts +++ b/packages/schema/src/config/app.ts @@ -45,7 +45,17 @@ export default defineUntypedSchema({ /** * The base path of your Nuxt application. * - * This can be set at runtime by setting the NUXT_APP_BASE_URL environment variable. + * For example: + * @example + * ```ts + * export default defineNuxtConfig({ + * app: { + * baseURL: '/prefix/' + * } + * }) + * ``` + * + * This can also be set at runtime by setting the NUXT_APP_BASE_URL environment variable. * @example * ```bash * NUXT_APP_BASE_URL=/prefix/ node .output/server/index.mjs @@ -63,6 +73,16 @@ export default defineUntypedSchema({ /** * An absolute URL to serve the public folder from (production-only). * + * For example: + * @example + * ```ts + * export default defineNuxtConfig({ + * app: { + * cdnURL: 'https://mycdn.org/' + * } + * }) + * ``` + * * This can be set to a different value at runtime by setting the `NUXT_APP_CDN_URL` environment variable. * @example * ```bash @@ -241,6 +261,7 @@ export default defineUntypedSchema({ /** * Boolean or a path to an HTML file with the contents of which will be inserted into any HTML page * rendered with `ssr: false`. + * * - If it is unset, it will use `~/app/spa-loading-template.html` file in one of your layers, if it exists. * - If it is false, no SPA loading indicator will be loaded. * - If true, Nuxt will look for `~/app/spa-loading-template.html` file in one of your layers, or a @@ -346,4 +367,36 @@ export default defineUntypedSchema({ css: { $resolve: (val: string[] | undefined) => (val ?? []).map((c: any) => c.src || c), }, + + /** + * An object that allows us to configure the `unhead` nuxt module. + */ + unhead: { + /** + * An object that will be passed to `renderSSRHead` to customize the output. + * + * @see https://unhead.unjs.io/setup/ssr/installation#options + * @type {typeof import('@unhead/schema').RenderSSRHeadOptions} + * + * @example + * ```ts + * export default defineNuxtConfig({ + * unhead: { + * renderSSRHeadOptions: { + * omitLineBreaks: true + * } + * }) + * ``` + * + */ + renderSSRHeadOptions: { + $resolve: async (val: Record | undefined, get) => { + const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + + return defu(val, { + omitLineBreaks: isV4, + }) + }, + }, + }, }) diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index d39cd4aac..5be66f821 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -165,7 +165,7 @@ export default defineUntypedSchema({ await get('dev') ? {} : { - 'vue': ['onBeforeMount', 'onMounted', 'onBeforeUpdate', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated', 'onBeforeUnmount'], + 'vue': ['onMounted', 'onUpdated', 'onUnmounted', 'onBeforeMount', 'onBeforeUpdate', 'onBeforeUnmount', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated'], '#app': ['definePayloadReviver', 'definePageMeta'], }, ), @@ -175,8 +175,8 @@ export default defineUntypedSchema({ await get('dev') ? {} : { - 'vue': ['onServerPrefetch', 'onRenderTracked', 'onRenderTriggered'], - '#app': ['definePayloadReducer', 'definePageMeta'], + 'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'], + '#app': ['definePayloadReducer', 'definePageMeta', 'onPrehydrate'], }, ), }, diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index f2d1eb65a..944bd5f2c 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -4,6 +4,7 @@ import { join, relative, resolve } from 'pathe' import { isDebug, isDevelopment, isTest } from 'std-env' import { defu } from 'defu' import { findWorkspaceDir } from 'pkg-types' +import { randomUUID } from 'uncrypto' import type { RuntimeConfig } from '../types/config' export default defineUntypedSchema({ @@ -105,6 +106,11 @@ export default defineUntypedSchema({ const srcDir = resolve(rootDir, 'app') if (!existsSync(srcDir)) { + for (const file of ['app.vue', 'App.vue']) { + if (existsSync(resolve(rootDir, file))) { + return rootDir + } + } const keys = ['assets', 'layouts', 'middleware', 'pages', 'plugins'] as const const dirs = await Promise.all(keys.map(key => get(`dir.${key}`) as Promise)) for (const dir of dirs) { @@ -148,6 +154,25 @@ export default defineUntypedSchema({ $resolve: async (val: string | undefined, get): Promise => resolve(await get('rootDir') as string, val || '.nuxt'), }, + /** + * For multi-app projects, the unique name of the Nuxt application. + */ + appId: { + $resolve: (val: string) => val ?? 'nuxt-app', + }, + + /** + * A unique identifier matching the build. This may contain the hash of the current state of the project. + */ + buildId: { + $resolve: async (val: string | undefined, get): Promise => { + if (typeof val === 'string') { return val } + + const [isDev, isTest] = await Promise.all([get('dev') as Promise, get('test') as Promise]) + return isDev ? 'dev' : isTest ? 'test' : randomUUID() + }, + }, + /** * Used to set the modules directories for path resolving (for example, webpack's * `resolveLoading`, `nodeExternals` and `postcss`). @@ -223,7 +248,8 @@ export default defineUntypedSchema({ * * Nuxt tries to resolve each item in the modules array using node require path * (in `node_modules`) and then will be resolved from project `srcDir` if `~` alias is used. - * @note Modules are executed sequentially so the order is important. + * @note Modules are executed sequentially so the order is important. First, the modules defined in `nuxt.config.ts` are loaded. Then, modules found in the `modules/` + * directory are executed, and they load in alphabetical order. * @example * ```js * modules: [ @@ -392,8 +418,10 @@ export default defineUntypedSchema({ ignoreOptions: undefined, /** - * Any file in `pages/`, `layouts/`, `middleware/` or `store/` will be ignored during - * building if its filename starts with the prefix specified by `ignorePrefix`. + * Any file in `pages/`, `layouts/`, `middleware/`, and `public/` directories will be ignored during + * the build process if its filename starts with the prefix specified by `ignorePrefix`. This is intended to prevent + * certain files from being processed or served in the built application. + * By default, the `ignorePrefix` is set to '-', ignoring any files starting with '-'. */ ignorePrefix: { $resolve: val => val ?? '-', @@ -512,11 +540,12 @@ export default defineUntypedSchema({ */ runtimeConfig: { $resolve: async (val: RuntimeConfig, get): Promise> => { - const app = await get('app') as Record + const [app, buildId] = await Promise.all([get('app') as Promise>, get('buildId') as Promise]) provideFallbackValues(val) return defu(val, { public: {}, app: { + buildId, baseURL: app.baseURL, buildAssetsDir: app.buildAssetsDir, cdnURL: app.cdnURL, diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index c6f5e3182..dbc24fedd 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -29,17 +29,28 @@ export default defineUntypedSchema({ * compileTemplate: true, * templateUtils: true, * relativeWatchPaths: true, + * resetAsyncDataToUndefined: true, * defaults: { * useAsyncData: { * deep: true * } * } + * }, + * unhead: { + * renderSSRHeadOptions: { + * omitLineBreaks: false + * } * } * }) * ``` * @type {3 | 4} */ compatibilityVersion: 3, + /** + * This enables early access to the experimental multi-app support. + * @see [Nuxt Issue #21635](https://github.com/nuxt/nuxt/issues/21635) + */ + multiApp: false, /** * This enables 'Bundler' module resolution mode for TypeScript, which is the recommended setting * for frameworks like Nuxt and Vite. @@ -131,8 +142,18 @@ export default defineUntypedSchema({ /** * Tree shakes contents of client-only components from server bundle. * @see [Nuxt PR #5750](https://github.com/nuxt/framework/pull/5750) + * @deprecated This option will no longer be configurable in Nuxt v4 */ - treeshakeClientOnly: true, + treeshakeClientOnly: { + async $resolve (val, get) { + const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + if (isV4 && val === false) { + console.warn('Enabling `experimental.treeshakeClientOnly` in v4 compatibility mode as it will no longer be configurable in Nuxt v4.') + return true + } + return val ?? true + }, + }, /** * Emit `app:chunkError` hook when there is an error loading vite/webpack @@ -248,19 +269,51 @@ export default defineUntypedSchema({ /** * Config schema support * @see [Nuxt Issue #15592](https://github.com/nuxt/nuxt/issues/15592) + * @deprecated This option will no longer be configurable in Nuxt v4 */ - configSchema: true, + configSchema: { + async $resolve (val, get) { + const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + if (isV4 && val === false) { + console.warn('Enabling `experimental.configSchema` in v4 compatibility mode as it will no longer be configurable in Nuxt v4.') + return true + } + return val ?? true + }, + }, /** * Whether or not to add a compatibility layer for modules, plugins or user code relying on the old * `@vueuse/head` API. * - * This can be disabled for most Nuxt sites to reduce the client-side bundle by ~0.5kb. + * This is disabled to reduce the client-side bundle by ~0.5kb. + * @deprecated This feature will be removed in Nuxt v4. */ - polyfillVueUseHead: false, + polyfillVueUseHead: { + async $resolve (val, get) { + const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + if (isV4 && val === true) { + console.warn('Disabling `experimental.polyfillVueUseHead` in v4 compatibility mode as it will no longer be configurable in Nuxt v4.') + return false + } + return val ?? false + }, + }, - /** Allow disabling Nuxt SSR responses by setting the `x-nuxt-no-ssr` header. */ - respectNoSSRHeader: false, + /** + * Allow disabling Nuxt SSR responses by setting the `x-nuxt-no-ssr` header. + * @deprecated This feature will be removed in Nuxt v4. + */ + respectNoSSRHeader: { + async $resolve (val, get) { + const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + if (isV4 && val === true) { + console.warn('Disabling `experimental.respectNoSSRHeader` in v4 compatibility mode as it will no longer be configurable in Nuxt v4.') + return false + } + return val ?? false + }, + }, /** Resolve `~`, `~~`, `@` and `@@` aliases located within layers with respect to their layer source and root directories. */ localLayerAliases: true, @@ -297,8 +350,10 @@ export default defineUntypedSchema({ /** * Use new experimental head optimisations: + * * - Add the capo.js head plugin in order to render tags in of the head in a more performant way. * - Uses the hash hydration plugin to reduce initial hydration + * * @see [Nuxt Discussion #22632](https://github.com/nuxt/nuxt/discussions/22632] */ headNext: true, @@ -347,7 +402,11 @@ export default defineUntypedSchema({ * }) * ``` */ - sharedPrerenderData: false, + sharedPrerenderData: { + async $resolve (val, get) { + return val ?? ((await get('future') as Record).compatibilityVersion === 4) + }, + }, /** * Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. @@ -370,6 +429,18 @@ export default defineUntypedSchema({ * Options that apply to `useAsyncData` (and also therefore `useFetch`) */ useAsyncData: { + /** @type {'undefined' | 'null'} */ + value: { + async $resolve (val, get) { + return val ?? ((await get('future') as Record).compatibilityVersion === 4 ? 'undefined' : 'null') + }, + }, + /** @type {'undefined' | 'null'} */ + errorValue: { + async $resolve (val, get) { + return val ?? ((await get('future') as Record).compatibilityVersion === 4 ? 'undefined' : 'null') + }, + }, deep: { async $resolve (val, get) { return val ?? !((await get('future') as Record).compatibilityVersion === 4) @@ -399,7 +470,7 @@ export default defineUntypedSchema({ * Whether to use `lodash.template` to compile Nuxt templates. * * This flag will be removed with the release of v4 and exists only for - * advance testing within Nuxt v3.12+. + * advance testing within Nuxt v3.12+ or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). */ compileTemplate: { async $resolve (val, get) { @@ -412,7 +483,7 @@ export default defineUntypedSchema({ * `importName` and `importSources`) when compiling Nuxt templates. * * This flag will be removed with the release of v4 and exists only for - * advance testing within Nuxt v3.12+. + * advance testing within Nuxt v3.12+ or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). */ templateUtils: { async $resolve (val, get) { @@ -424,12 +495,22 @@ export default defineUntypedSchema({ * Whether to provide relative paths in the `builder:watch` hook. * * This flag will be removed with the release of v4 and exists only for - * advance testing within Nuxt v3.12+. + * advance testing within Nuxt v3.12+ or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). */ relativeWatchPaths: { async $resolve (val, get) { return val ?? ((await get('future') as Record).compatibilityVersion !== 4) }, }, + + /** + * Whether `clear` and `clearNuxtData` should reset async data to its _default_ value or update + * it to `null`/`undefined`. + */ + resetAsyncDataToUndefined: { + async $resolve (val, get) { + return val ?? ((await get('future') as Record).compatibilityVersion !== 4) + }, + }, }, }) diff --git a/packages/schema/src/config/nitro.ts b/packages/schema/src/config/nitro.ts index d6ca241c1..f937bb2f2 100644 --- a/packages/schema/src/config/nitro.ts +++ b/packages/schema/src/config/nitro.ts @@ -1,4 +1,5 @@ import { defineUntypedSchema } from 'untyped' +import type { RuntimeConfig } from '../types/config' export default defineUntypedSchema({ /** @@ -7,6 +8,24 @@ export default defineUntypedSchema({ * @type {typeof import('nitropack')['NitroConfig']} */ nitro: { + runtimeConfig: { + $resolve: async (val: Record | undefined, get) => { + const runtimeConfig = await get('runtimeConfig') as RuntimeConfig + return { + ...runtimeConfig, + app: { + ...runtimeConfig.app, + baseURL: runtimeConfig.app.baseURL.startsWith('./') + ? runtimeConfig.app.baseURL.slice(1) + : runtimeConfig.app.baseURL, + }, + nitro: { + envPrefix: 'NUXT_', + ...runtimeConfig.nitro, + }, + } + }, + }, routeRules: { $resolve: async (val: Record | undefined, get) => ({ ...await get('routeRules') as Record, @@ -27,11 +46,13 @@ export default defineUntypedSchema({ * Nitro server handlers. * * Each handler accepts the following options: + * * - handler: The path to the file defining the handler. * - route: The route under which the handler is available. This follows the conventions of https://github.com/unjs/radix3. * - method: The HTTP method of requests that should be handled. * - middleware: Specifies whether it is a middleware handler. * - lazy: Specifies whether to use lazy loading to import the handler. + * * @see https://nuxt.com/docs/guide/directory-structure/server * @note Files from `server/api`, `server/middleware` and `server/routes` will be automatically registered by Nuxt. * @example diff --git a/packages/schema/src/config/typescript.ts b/packages/schema/src/config/typescript.ts index d8e019e4e..ccb8b54cb 100644 --- a/packages/schema/src/config/typescript.ts +++ b/packages/schema/src/config/typescript.ts @@ -41,6 +41,7 @@ export default defineUntypedSchema({ 'ofetch', // Key nuxt dependencies '@unhead/vue', + '@nuxt/devtools', 'vue', '@vue/runtime-core', '@vue/compiler-sfc', diff --git a/packages/schema/src/config/webpack.ts b/packages/schema/src/config/webpack.ts index 5ee21abc1..ad9787aea 100644 --- a/packages/schema/src/config/webpack.ts +++ b/packages/schema/src/config/webpack.ts @@ -157,7 +157,11 @@ export default defineUntypedSchema({ * See https://github.com/esbuild-kit/esbuild-loader * @type {Omit} */ - esbuild: {}, + esbuild: { + jsxFactory: 'h', + jsxFragment: 'Fragment', + tsconfigRaw: '{}', + }, /** * See: https://github.com/webpack-contrib/file-loader#options diff --git a/packages/schema/src/types/compatibility.ts b/packages/schema/src/types/compatibility.ts index 3344af856..51f4918bd 100644 --- a/packages/schema/src/types/compatibility.ts +++ b/packages/schema/src/types/compatibility.ts @@ -12,6 +12,29 @@ export interface NuxtCompatibility { * - `false`: When using Nuxt 2, using bridge module is not supported. */ bridge?: boolean + + /** + * Mark a builder as incompatible, or require a particular version. + * + * @example + * ```ts + * export default defineNuxtModule({ + * meta: { + * name: 'my-module', + * compatibility: { + * builder: { + * // marking as incompatible + * webpack: false, + * // you can require a (semver-compatible) version + * vite: '^5' + * } + * } + * } + * // ... + * }) + * ``` + */ + builder?: Partial> } export interface NuxtCompatibilityIssue { diff --git a/packages/ui-templates/lib/dev.ts b/packages/ui-templates/lib/dev.ts index a9d6ff55a..00a307c2f 100644 --- a/packages/ui-templates/lib/dev.ts +++ b/packages/ui-templates/lib/dev.ts @@ -23,7 +23,7 @@ export const DevRenderingPlugin = () => { const messages = JSON.parse(await fsp.readFile(r(page, 'messages.json'), 'utf-8')) return template(contents, { - interpolate: /{{{?([\s\S]+?)}?}}/g, + interpolate: /\{\{\{?([\s\S]+?)\}?\}\}/g, })({ messages: { ...genericMessages, ...messages }, }) diff --git a/packages/ui-templates/lib/render.ts b/packages/ui-templates/lib/render.ts index 449e6bc67..34ba7c189 100644 --- a/packages/ui-templates/lib/render.ts +++ b/packages/ui-templates/lib/render.ts @@ -1,9 +1,9 @@ +import { fileURLToPath } from 'node:url' import { readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'pathe' import type { Plugin } from 'vite' // @ts-expect-error https://github.com/GoogleChromeLabs/critters/pull/151 import Critters from 'critters' -import { template } from 'lodash-es' import { genObjectFromRawEntries } from 'knitwork' import htmlMinifier from 'html-minifier' import { globby } from 'globby' @@ -11,18 +11,20 @@ import { camelCase } from 'scule' import genericMessages from '../templates/messages.json' -const r = (...path: string[]) => resolve(join(__dirname, '..', ...path)) - +const r = (path: string) => fileURLToPath(new URL(join('..', path), import.meta.url)) const replaceAll = (input: string, search: string | RegExp, replace: string) => input.split(search).join(replace) export const RenderPlugin = () => { + let outputDir: string return { name: 'render', + configResolved (config) { + outputDir = r(config.build.outDir) + }, enforce: 'post', async writeBundle () { - const distDir = r('dist') - const critters = new Critters({ path: distDir }) - const htmlFiles = await globby(r('dist/templates/**/*.html')) + const critters = new Critters({ path: outputDir }) + const htmlFiles = await globby(resolve(outputDir, 'templates/**/*.html'), { absolute: true }) const templateExports = [] @@ -50,17 +52,17 @@ export const RenderPlugin = () => { .filter(src => src?.match(/\.svg$/)) for (const src of svgSources) { - const svg = readFileSync(r('dist', src), 'utf-8') + const svg = readFileSync(join(outputDir, src), 'utf-8') const base64Source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` html = replaceAll(html, src, base64Source) } // Inline our scripts - const scriptSources = Array.from(html.matchAll(/]*src="(.*)"[^>]*>[\s\S]*?<\/script>/g)) + const scriptSources = Array.from(html.matchAll(/]*src="([^"]*)"[^>]*>[\s\S]*?<\/script>/g)) .filter(([_block, src]) => src?.match(/^\/.*\.js$/)) for (const [scriptBlock, src] of scriptSources) { - let contents = readFileSync(r('dist', src), 'utf-8') + let contents = readFileSync(join(outputDir, src), 'utf-8') contents = replaceAll(contents, '/* empty css */', '').trim() html = html.replace(scriptBlock, contents.length ? `` : '') } @@ -77,35 +79,47 @@ export const RenderPlugin = () => { const messages = JSON.parse(readFileSync(r(`templates/${templateName}/messages.json`), 'utf-8')) // Serialize into a js function - const jsCode = [ - `const _messages = ${JSON.stringify({ ...genericMessages, ...messages })}`, - `const _render = ${template(html, { variable: '__var__', interpolate: /{{{?([\s\S]+?)}?}}/g }).toString().replace('__var__', '{ messages }')}`, - 'const _template = (messages) => _render({ messages: { ..._messages, ...messages } })', - ].join('\n').trim() + const chunks = html.split(/\{{2,3}[^{}]+\}{2,3}/g).map(chunk => JSON.stringify(chunk)) + const hasMessages = chunks.length > 1 + let templateString = chunks.shift() + for (const expression of html.matchAll(/\{{2,3}([^{}]+)\}{2,3}/g)) { + templateString += ` + (${expression[1].trim()}) + ${chunks.shift()}` + } + if (chunks.length > 0) { + templateString += ' + ' + chunks.join(' + ') + } + const functionalCode = [ + hasMessages ? `export type DefaultMessages = Record<${Object.keys({ ...genericMessages, ...messages }).map(a => `"${a}"`).join(' | ') || 'string'}, string | boolean | number >` : '', + hasMessages ? `const _messages = ${JSON.stringify({ ...genericMessages, ...messages })}` : '', + `export const template = (${hasMessages ? 'messages: Partial' : ''}) => {`, + hasMessages ? ' messages = { ..._messages, ...messages }' : '', + ` return ${templateString}`, + '}', + ].join('\n') const templateContent = html - .match(/([\s\S]*)<\/body>/)?.[0] - .replace(/(?<=<|<\/)body/g, 'div') + .match(/]*>([\s\S]*)<\/body>/)?.[0] + .replace(/(?<=<\/|<)body/g, 'div') .replace(/messages\./g, '') .replace(/]*>([\s\S]*?)<\/script>/g, '') .replace(/]*)>([\s\S]*)<\/a>/g, '\n$3\n') - .replace(/<([^>]+) ([a-z]+)="([^"]*)({{\s*(\w+?)\s*}})([^"]*)"([^>]*)>/g, '<$1 :$2="`$3${$5}$6`"$7>') - .replace(/>{{\s*(\w+?)\s*}}<\/[\w-]*>/g, ' v-text="$1" />') - .replace(/>{{{\s*(\w+?)\s*}}}<\/[\w-]*>/g, ' v-html="$1" />') + .replace(/<([^>]+) ([a-z]+)="([^"]*)(\{\{\s*(\w+)\s*\}\})([^"]*)"([^>]*)>/g, '<$1 :$2="`$3${$5}$6`"$7>') + .replace(/>\{\{\s*(\w+)\s*\}\}<\/[\w-]*>/g, ' v-text="$1" />') + .replace(/>\{\{\{\s*(\w+)\s*\}\}\}<\/[\w-]*>/g, ' v-html="$1" />') // We are not matching + * + * + * ``` + */ + const { id1, id2 } = html.match(/]* data-prehydrate-id=":(?[^:]+)::(?[^:]+):"> onPrehydrate testing <\/div>/)?.groups || {} + expect(id1).toBeTruthy() + const matches = [ + html.match(/]*>\(\(\)=>\{console.log\(window\)\}\)\(\)<\/script>/), + html.match(new RegExp(`]*>document.querySelectorAll\\('\\[data-prehydrate-id\\*=":${id1}:"]'\\).forEach\\(o=>{console.log\\(o.outerHTML\\)}\\)`)), + html.match(new RegExp(`]*>document.querySelectorAll\\('\\[data-prehydrate-id\\*=":${id2}:"]'\\).forEach\\(o=>{console.log\\("other",o.outerHTML\\)}\\)`)), + ] + + // This tests we inject all scripts correctly, and only have one occurrence of multiple calls of a composable + expect(matches.every(s => s?.length === 1)).toBeTruthy() + + // Check for hydration/syntax errors on client side + await expectNoClientErrors('/composables/on-prehydrate') + }) + it('respects preview mode with a token', async () => { const token = 'hehe' const page = await createPage(`/preview?preview=true&token=${token}`) @@ -635,7 +670,7 @@ describe('nuxt composables', () => { describe('rich payloads', () => { it('correctly serializes and revivifies complex types', async () => { - const html = await $fetch('/json-payload') + const html = await $fetch('/json-payload') for (const test of [ 'Date: true', 'BigInt: true', @@ -655,7 +690,7 @@ describe('rich payloads', () => { describe('nuxt links', () => { it('handles trailing slashes', async () => { - const html = await $fetch('/nuxt-link/trailing-slash') + const html = await $fetch('/nuxt-link/trailing-slash') const data: Record = {} for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) { data[selector] = [] @@ -773,7 +808,7 @@ describe('nuxt links', () => { }) it('useLink works', async () => { - const html = await $fetch('/nuxt-link/use-link') + const html = await $fetch('/nuxt-link/use-link') expect(html).toContain('
useLink in NuxtLink: true
') expect(html).toContain('
route using useLink: /nuxt-link/trailing-slash
') expect(html).toContain('
href using useLink: /nuxt-link/trailing-slash
') @@ -812,21 +847,21 @@ describe('nuxt links', () => { describe('head tags', () => { it('SSR should render tags', async () => { - const headHtml = await $fetch('/head') + const headHtml = await $fetch('/head') expect(headHtml).toContain('Using a dynamic component - Title Template Fn Change') expect(headHtml).not.toContain('') expect(headHtml).toContain('') - expect(headHtml.match('meta charset').length).toEqual(1) + expect(headHtml.match('meta charset')!.length).toEqual(1) expect(headHtml).toContain('') - expect(headHtml.match('meta name="viewport"').length).toEqual(1) + expect(headHtml.match('meta name="viewport"')!.length).toEqual(1) expect(headHtml).not.toContain('') expect(headHtml).toContain('') expect(headHtml).toMatch(/]*class="html-attrs-test"/) expect(headHtml).toMatch(/]*class="body-attrs-test"/) expect(headHtml).toContain('') - const indexHtml = await $fetch('/') + const indexHtml = await $fetch('/') // should render charset by default expect(indexHtml).toContain('') // should render components @@ -834,7 +869,7 @@ describe('head tags', () => { }) it('SSR script setup should render tags', async () => { - const headHtml = await $fetch('/head-script-setup') + const headHtml = await $fetch('/head-script-setup') // useHead - title & titleTemplate are working expect(headHtml).toContain('head script setup - Nuxt Playground') @@ -849,35 +884,35 @@ describe('head tags', () => { expect(headHtml).toContain('') }) - it('SPA should render appHead tags', async () => { - const headHtml = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } }) + it.skipIf(isV4)('SPA should render appHead tags', async () => { + const headHtml = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } }) expect(headHtml).toContain('') expect(headHtml).toContain('') expect(headHtml).toContain('') }) - it('legacy vueuse/head works', async () => { - const headHtml = await $fetch('/vueuse-head') + it.skipIf(isV4)('legacy vueuse/head works', async () => { + const headHtml = await $fetch('/vueuse-head') expect(headHtml).toContain('using provides usehead and updateDOM - VueUse head polyfill test') }) it('should render http-equiv correctly', async () => { - const html = await $fetch('/head') + const html = await $fetch('/head') // http-equiv should be rendered kebab case expect(html).toContain('') }) // TODO: Doesn't adds header in test environment // it.todo('should render stylesheet link tag (SPA mode)', async () => { - // const html = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } }) + // const html = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } }) // expect(html).toMatch(/ { it('should work with defineNuxtComponent', async () => { - const html = await $fetch('/legacy/async-data') + const html = await $fetch('/legacy/async-data') expect(html).toContain('
Hello API
') expect(html).toContain('
fooChild
') expect(html).toContain('
fooParent
') @@ -937,18 +972,24 @@ describe('navigate', () => { expect(status).toEqual(404) }) + + it('expect to redirect with encoding', async () => { + const { status } = await fetch('/redirect-with-encode', { redirect: 'manual' }) + + expect(status).toEqual(302) + }) }) describe('preserves current instance', () => { // TODO: it's unclear why there's an error here but it must be an upstream issue it.todo('should not return getCurrentInstance when there\'s an error in data', async () => { await fetch('/instance/error') - const html = await $fetch('/instance/next-request') + const html = await $fetch('/instance/next-request') expect(html).toContain('This should be false: false') }) // TODO: re-enable when https://github.com/nuxt/nuxt/issues/15164 is resolved it.skipIf(isWindows)('should not lose current nuxt app after await in vue component', async () => { - const requests = await Promise.all(Array.from({ length: 100 }).map(() => $fetch('/instance/next-request'))) + const requests = await Promise.all(Array.from({ length: 100 }).map(() => $fetch('/instance/next-request'))) for (const html of requests) { expect(html).toContain('This should be true: true') } @@ -1000,7 +1041,7 @@ describe('errors', () => { }) it('should not recursively throw an error when there is an error rendering the error page', async () => { - const res = await $fetch('/', { + const res = await $fetch('/', { headers: { 'x-test-recurse-error': 'true', 'accept': 'text/html', @@ -1043,7 +1084,7 @@ describe('navigate external', () => { describe('composables', () => { it('`callOnce` should run code once', async () => { - const html = await $fetch('/once') + const html = await $fetch('/once') expect(html).toContain('once.vue') expect(html).toContain('once: 2') @@ -1053,11 +1094,11 @@ describe('composables', () => { }) it('`useId` should generate unique ids', async () => { // TODO: work around interesting Vue bug where async components are loaded in a different order on first import - await $fetch('/use-id') + await $fetch('/use-id') const sanitiseHTML = (html: string) => html.replace(/ data-[^= ]+="[^"]+"/g, '').replace(//, '') - const serverHTML = await $fetch('/use-id').then(html => sanitiseHTML(html.match(//)![0])) + const serverHTML = await $fetch('/use-id').then(html => sanitiseHTML(html.match(//)![0])) const ids = serverHTML.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1')) as string[] const renderedForm = [ `

id: ${ids[0]}

`, @@ -1095,7 +1136,7 @@ describe('composables', () => { describe('middlewares', () => { it('should redirect to index with global middleware', async () => { - const html = await $fetch('/redirect/') + const html = await $fetch('/redirect/') // Snapshot // expect(html).toMatchInlineSnapshot() @@ -1113,7 +1154,7 @@ describe('middlewares', () => { }) it('should allow aborting navigation fatally on client-side', async () => { - const html = await $fetch('/middleware-abort') + const html = await $fetch('/middleware-abort') expect(html).not.toContain('This is the error page') const { page } = await renderPage('/middleware-abort') expect(await page.innerHTML('body')).toContain('This is the error page') @@ -1121,7 +1162,7 @@ describe('middlewares', () => { }) it('should inject auth', async () => { - const html = await $fetch('/auth') + const html = await $fetch('/auth') // Snapshot // expect(html).toMatchInlineSnapshot() @@ -1131,7 +1172,7 @@ describe('middlewares', () => { }) it('should not inject auth', async () => { - const html = await $fetch('/no-auth') + const html = await $fetch('/no-auth') // Snapshot // expect(html).toMatchInlineSnapshot() @@ -1150,12 +1191,12 @@ describe('middlewares', () => { describe('plugins', () => { it('basic plugin', async () => { - const html = await $fetch('/plugins') + const html = await $fetch('/plugins') expect(html).toContain('myPlugin: Injected by my-plugin') }) it('async plugin', async () => { - const html = await $fetch('/plugins') + const html = await $fetch('/plugins') expect(html).toContain('asyncPlugin: Async plugin works! 123') expect(html).toContain('useFetch works!') }) @@ -1163,7 +1204,7 @@ describe('plugins', () => { describe('layouts', () => { it('should apply custom layout', async () => { - const html = await $fetch('/with-layout') + const html = await $fetch('/with-layout') // Snapshot // expect(html).toMatchInlineSnapshot() @@ -1172,7 +1213,7 @@ describe('layouts', () => { expect(html).toContain('Custom Layout:') }) it('should work with a dynamically set layout', async () => { - const html = await $fetch('/with-dynamic-layout') + const html = await $fetch('/with-dynamic-layout') // Snapshot // expect(html).toMatchInlineSnapshot() @@ -1182,7 +1223,7 @@ describe('layouts', () => { await expectNoClientErrors('/with-dynamic-layout') }) it('should work with a computed layout', async () => { - const html = await $fetch('/with-computed-layout') + const html = await $fetch('/with-computed-layout') // Snapshot // expect(html).toMatchInlineSnapshot() @@ -1192,7 +1233,7 @@ describe('layouts', () => { await expectNoClientErrors('/with-computed-layout') }) it('should allow passing custom props to a layout', async () => { - const html = await $fetch('/layouts/with-props') + const html = await $fetch('/layouts/with-props') expect(html).toContain('some prop was passed') await expectNoClientErrors('/layouts/with-props') }) @@ -1200,7 +1241,7 @@ describe('layouts', () => { describe('composable tree shaking', () => { it('should work', async () => { - const html = await $fetch('/tree-shake') + const html = await $fetch('/tree-shake') expect(html).toContain('Tree Shake Example') @@ -1216,22 +1257,22 @@ describe('composable tree shaking', () => { describe('ignore list', () => { it('should ignore composable files in .nuxtignore', async () => { - const html = await $fetch('/ignore/composables') + const html = await $fetch('/ignore/composables') expect(html).toContain('was import ignored: true') }) it('should ignore scanned nitro handlers in .nuxtignore', async () => { - const html = await $fetch('/ignore/scanned') + const html = await $fetch('/ignore/scanned') expect(html).not.toContain('this should be ignored') }) it.skipIf(isDev())('should ignore public assets in .nuxtignore', async () => { - const html = await $fetch('/ignore/public-asset') + const html = await $fetch('/ignore/public-asset') expect(html).not.toContain('this should be ignored') }) }) describe('server tree shaking', () => { it('should work', async () => { - const html = await $fetch('/client') + const html = await $fetch('/client') expect(html).toContain('This page should not crash when rendered') expect(html).toContain('fallback for ClientOnly') @@ -1252,13 +1293,13 @@ describe('server tree shaking', () => { describe('extends support', () => { describe('layouts & pages', () => { it('extends foo/layouts/default & foo/pages/index', async () => { - const html = await $fetch('/foo') + const html = await $fetch('/foo') expect(html).toContain('Extended layout from foo') expect(html).toContain('Extended page from foo') }) it('extends [bar/layouts/override & bar/pages/override] over [foo/layouts/override & foo/pages/override]', async () => { - const html = await $fetch('/override') + const html = await $fetch('/override') expect(html).toContain('Extended layout from bar') expect(html).toContain('Extended page from bar') expect(html).toContain('This child page should not be overridden by bar') @@ -1267,62 +1308,62 @@ describe('extends support', () => { describe('components', () => { it('extends foo/components/ExtendsFoo', async () => { - const html = await $fetch('/foo') + const html = await $fetch('/foo') expect(html).toContain('Extended component from foo') }) it('extends bar/components/ExtendsOverride over foo/components/ExtendsOverride', async () => { - const html = await $fetch('/override') + const html = await $fetch('/override') expect(html).toContain('Extended component from bar') }) }) describe('middlewares', () => { it('works with layer aliases', async () => { - const html = await $fetch('/foo') + const html = await $fetch('/foo') expect(html).toContain('from layer alias') }) it('extends foo/middleware/foo', async () => { - const html = await $fetch('/foo') + const html = await $fetch('/foo') expect(html).toContain('Middleware | foo: Injected by extended middleware from foo') }) it('extends bar/middleware/override over foo/middleware/override', async () => { - const html = await $fetch('/override') + const html = await $fetch('/override') expect(html).toContain('Middleware | override: Injected by extended middleware from bar') }) it('global middlewares sorting', async () => { - const html = await $fetch('/middleware/ordering') + const html = await $fetch('/middleware/ordering') expect(html).toContain('catchall at middleware') }) }) describe('composables', () => { it('extends foo/composables/foo', async () => { - const html = await $fetch('/foo') + const html = await $fetch('/foo') expect(html).toContain('Composable | useExtendsFoo: foo') }) it('allows overriding composables', async () => { - const html = await $fetch('/extends') + const html = await $fetch('/extends') expect(html).toContain('test from project') }) }) describe('plugins', () => { it('extends foo/plugins/foo', async () => { - const html = await $fetch('/foo') + const html = await $fetch('/foo') expect(html).toContain('Plugin | foo: String generated from foo plugin!') }) it('respects plugin ordering within layers', async () => { - const html = await $fetch('/plugins/ordering') + const html = await $fetch('/plugins/ordering') expect(html).toContain('catchall at plugins') }) }) describe('server', () => { it('extends foo/server/api/foo', async () => { - expect(await $fetch('/api/foo')).toBe('foo') + expect(await $fetch('/api/foo')).toBe('foo') }) it('extends foo/server/middleware/foo', async () => { @@ -1333,7 +1374,7 @@ describe('extends support', () => { describe('app', () => { it('extends foo/app/router.options & bar/app/router.options', async () => { - const html: string = await $fetch('/') + const html: string = await $fetch('/') const routerLinkClasses = html.match(/href="\/" class="([^"]*)"/)?.[1].split(' ') expect(routerLinkClasses).toContain('foo-active-class') expect(routerLinkClasses).toContain('bar-exact-active-class') @@ -1572,7 +1613,7 @@ describe('layout switching', () => { describe('automatically keyed composables', () => { it('should automatically generate keys', async () => { - const html = await $fetch('/keyed-composables') + const html = await $fetch('/keyed-composables') expect(html).toContain('true') expect(html).not.toContain('false') }) @@ -1581,7 +1622,7 @@ describe('automatically keyed composables', () => { }) it('should not automatically generate keys', async () => { await expectNoClientErrors('/keyed-composables/local') - const html = await $fetch('/keyed-composables/local') + const html = await $fetch('/keyed-composables/local') expect(html).toContain('true') expect(html).not.toContain('false') }) @@ -1589,7 +1630,7 @@ describe('automatically keyed composables', () => { describe.runIf(isDev() && !isWebpack)('css links', () => { it('should not inject links to CSS files that are inlined', async () => { - const html = await $fetch('/inline-only-css') + const html = await $fetch('/inline-only-css') expect(html).toContain('--inline-only') expect(html).not.toContain('inline-only.css') expect(html).toContain('assets/plugin.css') @@ -1613,7 +1654,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { ] it('should inline styles', async () => { - const html = await $fetch('/styles') + const html = await $fetch('/styles') for (const style of inlinedCSS) { expect.soft(html).toContain(style) } @@ -1624,7 +1665,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { '{--plugin:"plugin"}', // CSS imported ambiently in JS/TS '{--global:"global";', // global css from nuxt.config ] - const html = await $fetch('/route-rules/spa') + const html = await $fetch('/route-rules/spa') for (const style of globalCSS) { expect.soft(html).toContain(style) } @@ -1638,11 +1679,11 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { }) it('should not include inlined CSS in generated CSS file', async () => { - const html: string = await $fetch('/styles') + const html: string = await $fetch('/styles') const cssFiles = new Set([...html.matchAll(/]*href="([^"]*\.css)">/g)].map(m => m[1])) let css = '' for (const file of cssFiles || []) { - css += await $fetch(file) + css += await $fetch(file) } // should not include inlined CSS in generated CSS files @@ -1659,7 +1700,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { }) it('does not load stylesheet for page styles', async () => { - const html: string = await $fetch('/styles') + const html: string = await $fetch('/styles') expect(html.match(/]*href="[^"]*\.css">/g)?.filter(m => m.includes('entry'))?.map(m => m.replace(/\.[^.]*\.css/, '.css'))).toMatchInlineSnapshot(` [ "", @@ -1675,7 +1716,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { }) it.todo('renders client-only styles only', async () => { - const html = await $fetch('/styles') + const html = await $fetch('/styles') expect(html).toContain('{--client-only:"client-only"}') }) }) @@ -1830,7 +1871,7 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('prefetching', () => { }) it('should not prefetch certain dynamic imports by default', async () => { - const html = await $fetch('/auth') + const html = await $fetch('/auth') // should not prefetch global components expect(html).not.toMatch(/]*\/_nuxt\/TestGlobal[^>]*\.js"/) // should not prefetch all other pages @@ -1866,7 +1907,7 @@ describe.runIf(isDev() && (!isWindows || !isCI))('detecting invalid root nodes', describe('public directories', () => { it('should directly return public directory paths', async () => { - const html = await $fetch('/assets-custom') + const html = await $fetch('/assets-custom') expect(html).toContain('"/public.svg"') expect(html).toContain('"/custom/file.svg"') }) @@ -1874,30 +1915,41 @@ describe('public directories', () => { // TODO: dynamic paths in dev describe.skipIf(isDev())('dynamic paths', () => { + const publicFiles = ['/public.svg', '/css-only-public-asset.svg'] + const isPublicFile = (base = '/', file: string) => { + if (isWebpack) { + // TODO: webpack does not yet support dynamic static paths + expect(publicFiles).toContain(file) + return true + } + + expect(file).toMatch(new RegExp(`^${base.replace(/\//g, '\\/')}`)) + expect(publicFiles).toContain(file.replace(base, '/')) + return true + } + it('should work with no overrides', async () => { - const html: string = await $fetch('/assets') - for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) { + const html: string = await $fetch('/assets') + for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*)\)/g)) { const url = match[2] || match[3] - expect(url.startsWith('/_nuxt/') || url === '/public.svg').toBeTruthy() + expect(url.startsWith('/_nuxt/') || isPublicFile('/', url)).toBeTruthy() } }) // webpack injects CSS differently it.skipIf(isWebpack)('adds relative paths to CSS', async () => { - const html: string = await $fetch('/assets') - const urls = Array.from(html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)).map(m => m[2] || m[3]) + const html: string = await $fetch('/assets') + const urls = Array.from(html.matchAll(/(href|src)="(.*?)"|url\(([^)]*)\)/g)).map(m => m[2] || m[3]) const cssURL = urls.find(u => /_nuxt\/assets.*\.css$/.test(u)) expect(cssURL).toBeDefined() - const css: string = await $fetch(cssURL!) - const imageUrls = Array.from(css.matchAll(/url\(([^)]*)\)/g)).map(m => m[1].replace(/[-.][\w]{8}\./g, '.')) - expect(imageUrls).toMatchInlineSnapshot(` - [ - "./logo.svg", - "../public.svg", - "../public.svg", - "../public.svg", - ] - `) + const css = await $fetch(cssURL!) + const imageUrls = new Set(Array.from(css.matchAll(/url\(([^)]*)\)/g)).map(m => m[1].replace(/[-.]\w{8}\./g, '.'))) + expect([...imageUrls]).toMatchInlineSnapshot(` + [ + "./logo.svg", + "../public.svg", + ] + `) }) it('should allow setting base URL and build assets directory', async () => { @@ -1908,18 +1960,13 @@ describe.skipIf(isDev())('dynamic paths', () => { }, }) - const html = await $fetch('/foo/assets') - for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) { + const html = await $fetch('/foo/assets') + for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*)\)/g)) { const url = match[2] || match[3] - expect( - url.startsWith('/foo/_other/') || - url === '/foo/public.svg' || - // TODO: webpack does not yet support dynamic static paths - (isWebpack && url === '/public.svg'), - ).toBeTruthy() + expect(url.startsWith('/foo/_other/') || isPublicFile('/foo/', url)).toBeTruthy() } - expect(await $fetch('/foo/url')).toContain('path: /foo/url') + expect(await $fetch('/foo/url')).toContain('path: /foo/url') }) it('should allow setting relative baseURL', async () => { @@ -1929,15 +1976,10 @@ describe.skipIf(isDev())('dynamic paths', () => { }, }) - const html = await $fetch('/assets') - for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) { + const html = await $fetch('/assets') + for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*)\)/g)) { const url = match[2] || match[3] - expect( - url.startsWith('./_nuxt/') || - url === './public.svg' || - // TODO: webpack does not yet support dynamic static paths - (isWebpack && url === '/public.svg'), - ).toBeTruthy() + expect(url.startsWith('./_nuxt/') || isPublicFile('./', url)).toBeTruthy() expect(url.startsWith('./_nuxt/_nuxt')).toBeFalsy() } }) @@ -1963,15 +2005,10 @@ describe.skipIf(isDev())('dynamic paths', () => { }, }) - const html = await $fetch('/foo/assets') - for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) { + const html = await $fetch('/foo/assets') + for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*)\)/g)) { const url = match[2] || match[3] - expect( - url.startsWith('https://example.com/_cdn/') || - url === 'https://example.com/public.svg' || - // TODO: webpack does not yet support dynamic static paths - (isWebpack && url === '/public.svg'), - ).toBeTruthy() + expect(url.startsWith('https://example.com/_cdn/') || isPublicFile('https://example.com/', url)).toBeTruthy() } }) @@ -1982,7 +2019,7 @@ describe.skipIf(isDev())('dynamic paths', () => { describe('app config', () => { it('should work', async () => { - const html = await $fetch('/app-config') + const html = await $fetch('/app-config') const expectedAppConfig: Record = { fromNuxtConfig: true, @@ -1993,22 +2030,16 @@ describe('app config', () => { fromLayer: true, userConfig: 123, } - if (isTestingAppManifest) { - expectedAppConfig.nuxt.buildId = 'test' - } - expect.soft(html.replace(/"nuxt":\{"buildId":"[^"]+"\}/, '"nuxt":{"buildId":"test"}')).toContain(JSON.stringify(expectedAppConfig)) + expect.soft(html).toContain(JSON.stringify(expectedAppConfig)) - const serverAppConfig = await $fetch('/api/app-config') - if (isTestingAppManifest) { - serverAppConfig.appConfig.nuxt.buildId = 'test' - } + const serverAppConfig = await $fetch>('/api/app-config') expect(serverAppConfig).toMatchObject({ appConfig: expectedAppConfig }) }) }) describe('component islands', () => { it('renders components with route', async () => { - const result: NuxtIslandResponse = await $fetch('/__nuxt_island/RouteComponent.json?url=/foo') + const result = await $fetch('/__nuxt_island/RouteComponent.json?url=/foo') result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '') if (isDev()) { @@ -2028,7 +2059,7 @@ describe('component islands', () => { }) it('render async component', async () => { - const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/LongAsyncComponent.json', { + const result = await $fetch(withQuery('/__nuxt_island/LongAsyncComponent.json', { props: JSON.stringify({ count: 3, }), @@ -2086,7 +2117,7 @@ describe('component islands', () => { }) it('render .server async component', async () => { - const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/AsyncServerComponent.json', { + const result = await $fetch(withQuery('/__nuxt_island/AsyncServerComponent.json', { props: JSON.stringify({ count: 2, }), @@ -2115,7 +2146,7 @@ describe('component islands', () => { if (!isWebpack) { it('render server component with selective client hydration', async () => { - const result: NuxtIslandResponse = await $fetch('/__nuxt_island/ServerWithClient') + const result = await $fetch('/__nuxt_island/ServerWithClient') if (isDev()) { result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates') && (l.href.startsWith('_nuxt/components/islands/') && l.href.includes('_nuxt/components/islands/AsyncServerComponent'))) } @@ -2149,7 +2180,7 @@ describe('component islands', () => { } it('renders pure components', async () => { - const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent.json', { + const result = await $fetch(withQuery('/__nuxt_island/PureComponent.json', { props: JSON.stringify({ bool: false, number: 3487, @@ -2160,11 +2191,10 @@ describe('component islands', () => { result.html = result.html.replace(/ data-island-uid="([^"]*)"/g, '') if (isDev()) { - result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates')) const fixtureDir = normalize(fileURLToPath(new URL('./fixtures/basic', import.meta.url))) for (const link of result.head.link) { link.href = link.href.replace(fixtureDir, '/').replaceAll('//', '/') - link.key = link.key.replace(/-[a-zA-Z0-9]+$/, '') + link.key = link.key.replace(/-[a-z0-9]+$/i, '') } result.head.link.sort((a, b) => b.href.localeCompare(a.href)) } @@ -2183,14 +2213,12 @@ describe('component islands', () => { } `) } else if (isDev() && !isWebpack) { + // TODO: resolve dev bug triggered by earlier fetch of /vueuse-head page + // https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/core/runtime/nitro/renderer.ts#L139 + result.head.link = result.head.link.filter(h => !h.href.includes('SharedComponent')) expect(result.head).toMatchInlineSnapshot(` { "link": [ - { - "href": "/_nuxt/components/SharedComponent.vue?vue&type=style&index=0&scoped=3ee84738&lang.css", - "key": "island-link", - "rel": "stylesheet", - }, { "href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css", "key": "island-link", @@ -2279,17 +2307,17 @@ describe('component islands', () => { describe.runIf(isDev() && !isWebpack)('vite plugins', () => { it('does not override vite plugins', async () => { - expect(await $fetch('/vite-plugin-without-path')).toBe('vite-plugin without path') - expect(await $fetch('/__nuxt-test')).toBe('vite-plugin with __nuxt prefix') + expect(await $fetch('/vite-plugin-without-path')).toBe('vite-plugin without path') + expect(await $fetch('/__nuxt-test')).toBe('vite-plugin with __nuxt prefix') }) it('does not allow direct access to nuxt source folder', async () => { - expect(await $fetch('/app.config')).toContain('catchall at') + expect(await $fetch('/app.config')).toContain('catchall at') }) }) describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', () => { it('renders a payload', async () => { - const payload = await $fetch('/random/a/_payload.json', { responseType: 'text' }) + const payload = await $fetch('/random/a/_payload.json', { responseType: 'text' }) const data = parsePayload(payload) expect(typeof data.prerenderedAt).toEqual('number') @@ -2344,7 +2372,7 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', ( }) it.skipIf(!isRenderingJson)('should not include server-component HTML in payload', async () => { - const payload = await $fetch('/prefetch/server-components/_payload.json', { responseType: 'text' }) + const payload = await $fetch('/prefetch/server-components/_payload.json', { responseType: 'text' }) const entries = Object.entries(parsePayload(payload)) const [key, serializedComponent] = entries.find(([key]) => key.startsWith('AsyncServerComponent')) || [] expect(serializedComponent).toEqual(key) @@ -2353,13 +2381,13 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', ( describe.skipIf(process.env.TEST_CONTEXT !== 'async')('Async context', () => { it('should be available', async () => { - expect(await $fetch('/async-context')).toContain('"hasApp": true') + expect(await $fetch('/async-context')).toContain('"hasApp": true') }) }) describe.skipIf(process.env.TEST_CONTEXT === 'async')('Async context', () => { it('should be unavailable', async () => { - expect(await $fetch('/async-context')).toContain('"hasApp": false') + expect(await $fetch('/async-context')).toContain('"hasApp": false') }) }) @@ -2379,7 +2407,7 @@ describe.skipIf(isWindows)('useAsyncData', () => { }) it('two requests resolve and sync', async () => { - await $fetch('/useAsyncData/refresh') + await $fetch('/useAsyncData/refresh') }) it('requests can be cancelled/overridden', async () => { @@ -2391,7 +2419,7 @@ describe.skipIf(isWindows)('useAsyncData', () => { }) it('requests status can be used', async () => { - const html = await $fetch('/useAsyncData/status') + const html = await $fetch('/useAsyncData/status') expect(html).toContain('true') expect(html).not.toContain('false') @@ -2401,21 +2429,23 @@ describe.skipIf(isWindows)('useAsyncData', () => { }) it('data is null after navigation when immediate false', async () => { + const defaultValue = isV4 ? 'undefined' : 'null' + const { page } = await renderPage('/useAsyncData/immediate-remove-unmounted') - expect(await page.locator('#immediate-data').getByText('null').textContent()).toBe('null') + expect(await page.locator('#immediate-data').getByText(defaultValue).textContent()).toBe(defaultValue) await page.click('#execute-btn') - expect(await page.locator('#immediate-data').getByText(',').textContent()).not.toContain('null') + expect(await page.locator('#immediate-data').getByText(',').textContent()).not.toContain(defaultValue) await page.click('#to-index') await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/') await page.click('#to-immediate-remove-unmounted') await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/useAsyncData/immediate-remove-unmounted') - expect(await page.locator('#immediate-data').getByText('null').textContent()).toBe('null') + expect(await page.locator('#immediate-data').getByText(defaultValue).textContent()).toBe(defaultValue) await page.click('#execute-btn') - expect(await page.locator('#immediate-data').getByText(',').textContent()).not.toContain('null') + expect(await page.locator('#immediate-data').getByText(',').textContent()).not.toContain(defaultValue) await page.close() }) @@ -2516,7 +2546,7 @@ describe('keepalive', () => { describe('teleports', () => { it('should append teleports to body', async () => { - const html = await $fetch('/teleport') + const html = await $fetch('/teleport') // Teleport is prepended to body, before the __nuxt div expect(html).toContain('
Teleport
') @@ -2524,7 +2554,7 @@ describe('teleports', () => { expect(html).toContain('

Normal content

') }) it('should render teleports to app teleports element', async () => { - const html = await $fetch('/nuxt-teleport') + const html = await $fetch('/nuxt-teleport') // Teleport is appended to body, after the __nuxt div expect(html).toContain('

Normal content

Nuxt Teleport
{ expect(await page.innerHTML('body')).toContain('CWD: [available]') await page.close() }) -}) +}, 20_000) function normaliseIslandResult (result: NuxtIslandResponse) { return { @@ -2548,7 +2578,7 @@ function normaliseIslandResult (result: NuxtIslandResponse) { style: result.head.style.map(s => ({ ...s, innerHTML: (s.innerHTML || '').replace(/data-v-[a-z0-9]+/, 'data-v-xxxxx').replace(/\.[a-zA-Z0-9]+\.svg/, '.svg'), - key: s.key.replace(/-[a-zA-Z0-9]+$/, ''), + key: s.key.replace(/-[a-z0-9]+$/i, ''), })), }, } @@ -2558,7 +2588,7 @@ describe('import components', () => { let html = '' it.sequential('fetch import-components page', async () => { - html = await $fetch('/import-components') + html = await $fetch('/import-components') }) it('load default component with mode all', () => { @@ -2590,7 +2620,7 @@ describe('lazy import components', () => { let html = '' it.sequential('fetch lazy-import-components page', async () => { - html = await $fetch('/lazy-import-components') + html = await $fetch('/lazy-import-components') }) it('lazy load named component with mode all', () => { @@ -2614,3 +2644,52 @@ describe('lazy import components', () => { await response }) }) + +describe('defineNuxtComponent watch duplicate', () => { + it('test after navigation duplicate', async () => { + const { page } = await renderPage('/define-nuxt-component') + await page.getByTestId('define-nuxt-component-bar').click() + await page.getByTestId('define-nuxt-component-state').click() + await page.getByTestId('define-nuxt-component-foo').click() + expect(await page.getByTestId('define-nuxt-component-state').first().innerText()).toBe('2') + }) +}) + +describe('namespace access to useNuxtApp', () => { + it('should return the nuxt instance when used with correct appId', async () => { + const { page, pageErrors } = await renderPage('/namespace-nuxt-app') + + expect(pageErrors).toEqual([]) + + await page.waitForFunction(() => window.useNuxtApp?.() && !window.useNuxtApp?.().isHydrating) + + // Defaulting to appId + await page.evaluate(() => window.useNuxtApp?.()) + // Using correct configured appId + // @ts-expect-error not public API yet + await page.evaluate(() => window.useNuxtApp?.('nuxt-app-basic')) + + await page.close() + }) + + it('should throw an error when used with wrong appId', async () => { + const { page, pageErrors } = await renderPage('/namespace-nuxt-app') + + expect(pageErrors).toEqual([]) + + await page.waitForFunction(() => window.useNuxtApp?.() && !window.useNuxtApp?.().isHydrating) + + let error: unknown + try { + // Using wrong/unknown appId + // @ts-expect-error not public API yet + await page.evaluate(() => window.useNuxtApp?.('nuxt-app-unknown')) + } catch (err) { + error = err + } + + expect(error).toBeTruthy() + + await page.close() + }) +}) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 5ca9d6403..6f9dd4fe4 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -75,7 +75,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"527k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) - expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"76.0k"`) + expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"76.1k"`) const packages = modules.files .filter(m => m.endsWith('package.json')) diff --git a/test/fixtures/basic-types/components/ServerComponent.server.vue b/test/fixtures/basic-types/components/ServerComponent.server.vue new file mode 100644 index 000000000..3b082ccf7 --- /dev/null +++ b/test/fixtures/basic-types/components/ServerComponent.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic-types/types.ts b/test/fixtures/basic-types/types.ts index f20bf6d55..586fb7c52 100644 --- a/test/fixtures/basic-types/types.ts +++ b/test/fixtures/basic-types/types.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import type { Ref } from 'vue' +import type { Ref, SlotsType } from 'vue' import type { FetchError } from 'ofetch' import type { NavigationFailure, RouteLocationNormalized, RouteLocationRaw, Router, useRouter as vueUseRouter } from '#vue-router' @@ -8,9 +8,12 @@ import { defineNuxtConfig } from 'nuxt/config' import { callWithNuxt, isVue3 } from '#app' import type { NuxtError } from '#app' import type { NavigateToOptions } from '#app/composables/router' -import { NuxtLayout, NuxtLink, NuxtPage, WithTypes } from '#components' +import { NuxtLayout, NuxtLink, NuxtPage, ServerComponent, WithTypes } from '#components' import { useRouter } from '#imports' +// TODO: temporary module for backwards compatibility +import type { DefaultAsyncDataErrorValue, DefaultAsyncDataValue } from '#app/defaults' + interface TestResponse { message: string } describe('API routes', () => { @@ -31,61 +34,61 @@ describe('API routes', () => { }) it('works with useAsyncData', () => { - expectTypeOf(useAsyncData('api-hello', () => $fetch('/api/hello')).data).toEqualTypeOf>() - expectTypeOf(useAsyncData('api-hey', () => $fetch('/api/hey')).data).toEqualTypeOf>() - expectTypeOf(useAsyncData('api-hey-with-pick', () => $fetch('/api/hey'), { pick: ['baz'] }).data).toEqualTypeOf>() - expectTypeOf(useAsyncData('api-union', () => $fetch('/api/union')).data).toEqualTypeOf>() - expectTypeOf(useAsyncData('api-union-with-pick', () => $fetch('/api/union'), { pick: ['type'] }).data).toEqualTypeOf>() + expectTypeOf(useAsyncData('api-hello', () => $fetch('/api/hello')).data).toEqualTypeOf>() + expectTypeOf(useAsyncData('api-hey', () => $fetch('/api/hey')).data).toEqualTypeOf>() + expectTypeOf(useAsyncData('api-hey-with-pick', () => $fetch('/api/hey'), { pick: ['baz'] }).data).toEqualTypeOf>() + expectTypeOf(useAsyncData('api-union', () => $fetch('/api/union')).data).toEqualTypeOf>() + expectTypeOf(useAsyncData('api-union-with-pick', () => $fetch('/api/union'), { pick: ['type'] }).data).toEqualTypeOf>() expectTypeOf(useAsyncData('api-other', () => $fetch('/api/other')).data).toEqualTypeOf>() - expectTypeOf(useAsyncData('api-generics', () => $fetch('/test')).data).toEqualTypeOf>() + expectTypeOf(useAsyncData('api-generics', () => $fetch('/test')).data).toEqualTypeOf>() - expectTypeOf(useAsyncData('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf | null>>() - expectTypeOf(useAsyncData('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf | null>>() + expectTypeOf(useAsyncData('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf | DefaultAsyncDataErrorValue>>() + expectTypeOf(useAsyncData('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf | DefaultAsyncDataErrorValue>>() // backwards compatibility - expectTypeOf(useAsyncData('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf>() - expectTypeOf(useAsyncData>('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf | null>>() + expectTypeOf(useAsyncData('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf>() + expectTypeOf(useAsyncData>('api-error-generics', () => $fetch('/error')).error).toEqualTypeOf | DefaultAsyncDataErrorValue>>() - expectTypeOf(useLazyAsyncData('lazy-api-hello', () => $fetch('/api/hello')).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-api-hey', () => $fetch('/api/hey')).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-api-hey-with-pick', () => $fetch('/api/hey'), { pick: ['baz'] }).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-api-union', () => $fetch('/api/union')).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-api-union-with-pick', () => $fetch('/api/union'), { pick: ['type'] }).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-api-hello', () => $fetch('/api/hello')).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-api-hey', () => $fetch('/api/hey')).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-api-hey-with-pick', () => $fetch('/api/hey'), { pick: ['baz'] }).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-api-union', () => $fetch('/api/union')).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-api-union-with-pick', () => $fetch('/api/union'), { pick: ['type'] }).data).toEqualTypeOf>() expectTypeOf(useLazyAsyncData('lazy-api-other', () => $fetch('/api/other')).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-api-generics', () => $fetch('/test')).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-api-generics', () => $fetch('/test')).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-error-generics', () => $fetch('/error')).error).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData('lazy-error-generics', () => $fetch('/error')).error).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-error-generics', () => $fetch('/error')).error).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData('lazy-error-generics', () => $fetch('/error')).error).toEqualTypeOf>() }) it('works with useFetch', () => { - expectTypeOf(useFetch('/api/hello').data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/hey').data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/hey', { method: 'GET' }).data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/hey', { method: 'get' }).data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/hey', { method: 'POST' }).data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/hey', { method: 'post' }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hello').data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hey').data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hey', { method: 'GET' }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hey', { method: 'get' }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hey', { method: 'POST' }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hey', { method: 'post' }).data).toEqualTypeOf>() // @ts-expect-error not a valid method useFetch('/api/hey', { method: 'PATCH' }) - expectTypeOf(useFetch('/api/hey', { pick: ['baz'] }).data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/union').data).toEqualTypeOf>() - expectTypeOf(useFetch('/api/union', { pick: ['type'] }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hey', { pick: ['baz'] }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/union').data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/union', { pick: ['type'] }).data).toEqualTypeOf>() expectTypeOf(useFetch('/api/other').data).toEqualTypeOf>() - expectTypeOf(useFetch('/test').data).toEqualTypeOf>() - expectTypeOf(useFetch('/test', { method: 'POST' }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/test').data).toEqualTypeOf>() + expectTypeOf(useFetch('/test', { method: 'POST' }).data).toEqualTypeOf>() - expectTypeOf(useFetch('/error').error).toEqualTypeOf>() - expectTypeOf(useFetch('/error').error).toEqualTypeOf>() + expectTypeOf(useFetch('/error').error).toEqualTypeOf>() + expectTypeOf(useFetch('/error').error).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/api/hello').data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/api/hey').data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/api/hey', { pick: ['baz'] }).data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/api/union').data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/api/union', { pick: ['type'] }).data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/api/hello').data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/api/hey').data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/api/hey', { pick: ['baz'] }).data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/api/union').data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/api/union', { pick: ['type'] }).data).toEqualTypeOf>() expectTypeOf(useLazyFetch('/api/other').data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/test').data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/test').data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/error').error).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/error').error).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/error').error).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/error').error).toEqualTypeOf>() }) }) @@ -372,6 +375,9 @@ describe('components', () => { // TODO: assert typed slots, exposed, generics, etc. }) + it('include fallback slot in server components', () => { + expectTypeOf(ServerComponent.slots).toEqualTypeOf | undefined>() + }) }) describe('composables', () => { @@ -418,10 +424,10 @@ describe('composables', () => { expectTypeOf(useLazyAsyncData(() => $fetch('/test'), { default: () => 'test' }).data).toEqualTypeOf>() // transform must match the explicit generic because of typescript limitations microsoft/TypeScript#14400 - expectTypeOf(useFetch('/test', { transform: () => 'transformed' }).data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/test', { transform: () => 'transformed' }).data).toEqualTypeOf>() - expectTypeOf(useAsyncData(() => $fetch('/test'), { transform: () => 'transformed' }).data).toEqualTypeOf>() - expectTypeOf(useLazyAsyncData(() => $fetch('/test'), { transform: () => 'transformed' }).data).toEqualTypeOf>() + expectTypeOf(useFetch('/test', { transform: () => 'transformed' }).data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/test', { transform: () => 'transformed' }).data).toEqualTypeOf>() + expectTypeOf(useAsyncData(() => $fetch('/test'), { transform: () => 'transformed' }).data).toEqualTypeOf>() + expectTypeOf(useLazyAsyncData(() => $fetch('/test'), { transform: () => 'transformed' }).data).toEqualTypeOf>() expectTypeOf(useFetch('/test', { default: () => 'test', transform: () => 'transformed' }).data).toEqualTypeOf>() expectTypeOf(useLazyFetch('/test', { default: () => 'test', transform: () => 'transformed' }).data).toEqualTypeOf>() @@ -436,7 +442,7 @@ describe('composables', () => { return data.foo }, }) - expectTypeOf(data).toEqualTypeOf>() + expectTypeOf(data).toEqualTypeOf>() }) it('infer request url string literal from server/api routes', () => { @@ -445,8 +451,8 @@ describe('composables', () => { expectTypeOf(useFetch(dynamicStringUrl).data).toEqualTypeOf>() // request param should infer string literal type / show auto-complete hint base on server routes, ex: '/api/hello' - expectTypeOf(useFetch('/api/hello').data).toEqualTypeOf>() - expectTypeOf(useLazyFetch('/api/hello').data).toEqualTypeOf>() + expectTypeOf(useFetch('/api/hello').data).toEqualTypeOf>() + expectTypeOf(useLazyFetch('/api/hello').data).toEqualTypeOf>() // request can accept string literal and Request object type expectTypeOf(useFetch('https://example.com/api').data).toEqualTypeOf>() @@ -516,7 +522,7 @@ describe('composables', () => { it('correctly types returns when using with getCachedData', () => { expectTypeOf(useAsyncData('test', () => Promise.resolve({ foo: 1 }), { getCachedData: key => useNuxtApp().payload.data[key], - }).data).toEqualTypeOf>() + }).data).toEqualTypeOf>() useAsyncData('test', () => Promise.resolve({ foo: 1 }), { // @ts-expect-error cached data should return the same as value of fetcher getCachedData: () => ({ bar: 2 }), @@ -531,7 +537,7 @@ describe('composables', () => { describe('app config', () => { it('merges app config as expected', () => { interface ExpectedMergedAppConfig { - nuxt: { buildId: string } + nuxt: {} fromLayer: boolean fromNuxtConfig: boolean nested: { diff --git a/test/fixtures/basic/app.config.ts b/test/fixtures/basic/app.config.js similarity index 69% rename from test/fixtures/basic/app.config.ts rename to test/fixtures/basic/app.config.js index 34dff0c5a..229ea39f7 100644 --- a/test/fixtures/basic/app.config.ts +++ b/test/fixtures/basic/app.config.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-undef export default defineAppConfig({ userConfig: 123, nested: { diff --git a/test/fixtures/basic/assets/global.css b/test/fixtures/basic/assets/global.css index 3d0b218a7..81bead531 100644 --- a/test/fixtures/basic/assets/global.css +++ b/test/fixtures/basic/assets/global.css @@ -1,4 +1,5 @@ :root { --global: 'global'; --asset: url('~/assets/css-only-asset.svg'); + --public-asset: url('/css-only-public-asset.svg'); } diff --git a/test/fixtures/basic/layers/bar/nuxt.config.ts b/test/fixtures/basic/layers/bar/nuxt.config.ts new file mode 100644 index 000000000..cf9614f66 --- /dev/null +++ b/test/fixtures/basic/layers/bar/nuxt.config.ts @@ -0,0 +1,8 @@ +export default defineNuxtConfig({ + modules: [ + function (_options, nuxt) { + // @ts-expect-error not valid nuxt option + nuxt.options.__installed_layer = true + }, + ], +}) diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 130d0f7bb..d312d5594 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -32,6 +32,7 @@ export default defineNuxtConfig({ }, buildDir: process.env.NITRO_BUILD_DIR, builder: process.env.TEST_BUILDER as 'webpack' | 'vite' ?? 'vite', + appId: 'nuxt-app-basic', build: { transpile: [ (ctx) => { @@ -91,6 +92,14 @@ export default defineNuxtConfig({ }, }, modules: [ + function (_options, nuxt) { + nuxt.hook('modules:done', () => { + // @ts-expect-error not valid nuxt option + if (!nuxt.options.__installed_layer) { + throw new Error('layer in layers/ directory was not auto-registered') + } + }) + }, '~/modules/subpath', './modules/test', '~/modules/example', @@ -226,6 +235,7 @@ export default defineNuxtConfig({ treeshakeClientOnly: true, asyncContext: process.env.TEST_CONTEXT === 'async', appManifest: process.env.TEST_MANIFEST !== 'manifest-off', + renderJsonPayloads: process.env.TEST_PAYLOAD !== 'js', headNext: true, inlineRouteRules: true, }, diff --git a/test/fixtures/basic/pages/assets-custom.vue b/test/fixtures/basic/pages/assets-custom.vue index 2eb1ba9b0..5f5f85243 100644 --- a/test/fixtures/basic/pages/assets-custom.vue +++ b/test/fixtures/basic/pages/assets-custom.vue @@ -1,6 +1,7 @@ diff --git a/test/fixtures/basic/pages/composables/on-prehydrate.vue b/test/fixtures/basic/pages/composables/on-prehydrate.vue new file mode 100644 index 000000000..ceaf066aa --- /dev/null +++ b/test/fixtures/basic/pages/composables/on-prehydrate.vue @@ -0,0 +1,22 @@ + + + diff --git a/test/fixtures/basic/pages/define-nuxt-component/index.vue b/test/fixtures/basic/pages/define-nuxt-component/index.vue new file mode 100644 index 000000000..cdbaac8d4 --- /dev/null +++ b/test/fixtures/basic/pages/define-nuxt-component/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/test/fixtures/basic/pages/define-nuxt-component/nested/[foo].vue b/test/fixtures/basic/pages/define-nuxt-component/nested/[foo].vue new file mode 100644 index 000000000..0081c2e47 --- /dev/null +++ b/test/fixtures/basic/pages/define-nuxt-component/nested/[foo].vue @@ -0,0 +1,34 @@ + + + diff --git a/test/fixtures/basic/pages/namespace-nuxt-app.vue b/test/fixtures/basic/pages/namespace-nuxt-app.vue new file mode 100644 index 000000000..66a5a0d05 --- /dev/null +++ b/test/fixtures/basic/pages/namespace-nuxt-app.vue @@ -0,0 +1,10 @@ + + + diff --git a/test/fixtures/basic/pages/redirect-with-encode.vue b/test/fixtures/basic/pages/redirect-with-encode.vue new file mode 100644 index 000000000..a59a44d65 --- /dev/null +++ b/test/fixtures/basic/pages/redirect-with-encode.vue @@ -0,0 +1,9 @@ + + + diff --git a/test/fixtures/basic/pages/useAsyncData/immediate-remove-unmounted.vue b/test/fixtures/basic/pages/useAsyncData/immediate-remove-unmounted.vue index 8e6b5a295..9ed094026 100644 --- a/test/fixtures/basic/pages/useAsyncData/immediate-remove-unmounted.vue +++ b/test/fixtures/basic/pages/useAsyncData/immediate-remove-unmounted.vue @@ -2,7 +2,7 @@
immediate-remove-unmounted.vue
- {{ data === null ? "null" : data }} + {{ data === null ? "null" : (data === undefined ? 'undefined' : data) }}