Merge branch 'main' into patch-21

This commit is contained in:
Michael Brevard 2024-08-13 18:22:59 +03:00 committed by GitHub
commit 8d066205c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 1835 additions and 916 deletions

View File

@ -57,7 +57,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Cache dist - name: Cache dist
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
retention-days: 3 retention-days: 3
name: dist name: dist
@ -85,7 +85,7 @@ jobs:
run: pnpm install run: pnpm install
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 uses: github/codeql-action/init@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
with: with:
languages: javascript languages: javascript
queries: +security-and-quality queries: +security-and-quality
@ -97,7 +97,7 @@ jobs:
path: packages path: packages
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 uses: github/codeql-action/analyze@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
with: with:
category: "/language:javascript" category: "/language:javascript"

View File

@ -59,7 +59,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab. # format to the repository Actions tab.
- name: "Upload artifact" - name: "Upload artifact"
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
if: github.repository == 'nuxt/nuxt' && success() if: github.repository == 'nuxt/nuxt' && success()
with: with:
name: SARIF file name: SARIF file
@ -68,7 +68,7 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 uses: github/codeql-action/upload-sarif@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
if: github.repository == 'nuxt/nuxt' && success() if: github.repository == 'nuxt/nuxt' && success()
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View File

@ -161,7 +161,13 @@ export default defineVitestConfig({
#### `mountSuspended` #### `mountSuspended`
`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: `mountSuspended` allows you to mount any Vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins.
::alert{type=info}
Under the hood, `mountSuspended` wraps `mount` from `@vue/test-utils`, so you can check out [the Vue Test Utils documentation](https://test-utils.vuejs.org/guide/) for more on the options you can pass, and how to use this utility.
::
For example:
```ts twoslash ```ts twoslash
import { it, expect } from 'vitest' import { it, expect } from 'vitest'
@ -207,6 +213,7 @@ it('can also mount an app', async () => {
`renderSuspended` allows you to render any Vue component within the Nuxt environment using `@testing-library/vue`, allowing async setup and access to injections from your Nuxt plugins. `renderSuspended` allows you to render any Vue component within the Nuxt environment using `@testing-library/vue`, allowing async setup and access to injections from your Nuxt plugins.
This should be used together with utilities from Testing Library, e.g. `screen` and `fireEvent`. Install [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro) in your project to use these. This should be used together with utilities from Testing Library, e.g. `screen` and `fireEvent`. Install [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro) in your project to use these.
Additionally, Testing Library also relies on testing globals for cleanup. You should turn these on in your [Vitest config](https://vitest.dev/config/#globals). Additionally, Testing Library also relies on testing globals for cleanup. You should turn these on in your [Vitest config](https://vitest.dev/config/#globals).
The passed in component will be rendered inside a `<div id="test-wrapper"></div>`. The passed in component will be rendered inside a `<div id="test-wrapper"></div>`.
@ -266,7 +273,9 @@ mockNuxtImport('useStorage', () => {
// your tests here // your tests here
``` ```
> **Note**: `mockNuxtImport` can only be used once per mocked import per test file. It is actually a macro that gets transformed to `vi.mock` and `vi.mock` is hoisted, as described [here](https://vitest.dev/api/vi.html#vi-mock). ::alert{type=info}
`mockNuxtImport` can only be used once per mocked import per test file. It is actually a macro that gets transformed to `vi.mock` and `vi.mock` is hoisted, as described [here](https://vitest.dev/api/vi.html#vi-mock).
::
If you need to mock a Nuxt import and provide different implementations between tests, you can do it by creating and exposing your mocks using [`vi.hoisted`](https://vitest.dev/api/vi.html#vi-hoisted), and then use those mocks in `mockNuxtImport`. You then have access to the mocked imports, and can change the implementation between tests. Be careful to [restore mocks](https://vitest.dev/api/mock.html#mockrestore) before or after each test to undo mock state changes between runs. If you need to mock a Nuxt import and provide different implementations between tests, you can do it by creating and exposing your mocks using [`vi.hoisted`](https://vitest.dev/api/vi.html#vi-hoisted), and then use those mocks in `mockNuxtImport`. You then have access to the mocked imports, and can change the implementation between tests. Be careful to [restore mocks](https://vitest.dev/api/mock.html#mockrestore) before or after each test to undo mock state changes between runs.

View File

@ -12,19 +12,19 @@ To enable type-checking at build or development time, install `vue-tsc` and `typ
::code-group ::code-group
```bash [yarn] ```bash [yarn]
yarn add --dev vue-tsc@^1 typescript yarn add --dev vue-tsc typescript
``` ```
```bash [npm] ```bash [npm]
npm install --save-dev vue-tsc@^1 typescript npm install --save-dev vue-tsc typescript
``` ```
```bash [pnpm] ```bash [pnpm]
pnpm add -D vue-tsc@^1 typescript pnpm add -D vue-tsc typescript
``` ```
```bash [bun] ```bash [bun]
bun add -D vue-tsc@^1 typescript bun add -D vue-tsc typescript
``` ```
:: ::

View File

@ -6,7 +6,7 @@ navigation.icon: i-ph-folder-duotone
--- ---
::note ::note
To reduce your application's bundle size, this directory is **optional**, meaning that [`vue-router`](https://router.vuejs.org) won't be included if you only use [`app.vue`](/docs/guide/directory-structure/app). To force the pages system, set `pages: true` in `nuxt.config` or have a [`app/router.options.ts`](/docs/guide/going-further/custom-routing#using-approuteroptions). To reduce your application's bundle size, this directory is **optional**, meaning that [`vue-router`](https://router.vuejs.org) won't be included if you only use [`app.vue`](/docs/guide/directory-structure/app). To force the pages system, set `pages: true` in `nuxt.config` or have a [`app/router.options.ts`](/docs/guide/recipes/custom-routing#using-approuteroptions).
:: ::
## Usage ## Usage
@ -226,6 +226,22 @@ definePageMeta({
:link-example{to="/docs/examples/routing/pages"} :link-example{to="/docs/examples/routing/pages"}
## Route Groups
In some cases, you may want to group a set of routes together in a way which doesn't affect file-based routing. For this purpose, you can put files in a folder which is wrapped in parentheses - `(` and `)`.
For example:
```bash [Directory structure]
-| pages/
---| index.vue
---| (marketing)/
-----| about.vue
-----| contact.vue
```
This will produce `/`, `/about` and `/contact` pages in your app. The `marketing` group is ignored for purposes of your URL structure.
## Page Metadata ## Page Metadata
You might want to define metadata for each route in your app. You can do this using the `definePageMeta` macro, which will work both in `<script>` and in `<script setup>`: You might want to define metadata for each route in your app. You can do this using the `definePageMeta` macro, which will work both in `<script>` and in `<script setup>`:
@ -381,7 +397,7 @@ Server-only pages must have a single root element. (HTML comments are considered
As your app gets bigger and more complex, your routing might require more flexibility. For this reason, Nuxt directly exposes the router, routes and router options for customization in different ways. As your app gets bigger and more complex, your routing might require more flexibility. For this reason, Nuxt directly exposes the router, routes and router options for customization in different ways.
:read-more{to="/docs/guide/going-further/custom-routing"} :read-more{to="/docs/guide/recipes/custom-routing"}
## Multiple Pages Directories ## Multiple Pages Directories

View File

@ -9,14 +9,14 @@ In Nuxt 3, your routing is defined by the structure of your files inside the [pa
### Router Config ### Router Config
Using [router options](/docs/guide/going-further/custom-routing#router-options), you can optionally override or extend your routes using a function that accepts the scanned routes and returns customized routes. Using [router options](/docs/guide/recipes/custom-routing#router-options), you can optionally override or extend your routes using a function that accepts the scanned routes and returns customized routes.
If it returns `null` or `undefined`, Nuxt will fall back to the default routes (useful to modify input array). If it returns `null` or `undefined`, Nuxt will fall back to the default routes (useful to modify input array).
```ts [app/router.options.ts] ```ts [app/router.options.ts]
import type { RouterConfig } from '@nuxt/schema' import type { RouterConfig } from '@nuxt/schema'
export default <RouterConfig> { export default {
// https://router.vuejs.org/api/interfaces/routeroptions.html#routes // https://router.vuejs.org/api/interfaces/routeroptions.html#routes
routes: (_routes) => [ routes: (_routes) => [
{ {
@ -25,7 +25,7 @@ export default <RouterConfig> {
component: () => import('~/pages/home.vue').then(r => r.default || r) component: () => import('~/pages/home.vue').then(r => r.default || r)
} }
], ],
} } satisfies RouterConfig
``` ```
::note ::note
@ -90,8 +90,8 @@ This is the recommended way to specify [router options](/docs/api/nuxt-config#ro
```ts [app/router.options.ts] ```ts [app/router.options.ts]
import type { RouterConfig } from '@nuxt/schema' import type { RouterConfig } from '@nuxt/schema'
export default <RouterConfig> { export default {
} } satisfies RouterConfig
``` ```
It is possible to add more router options files by adding files within the `pages:routerOptions` hook. Later items in the array override earlier ones. It is possible to add more router options files by adding files within the `pages:routerOptions` hook. Later items in the array override earlier ones.
@ -174,8 +174,8 @@ You can optionally override history mode using a function that accepts the base
import type { RouterConfig } from '@nuxt/schema' import type { RouterConfig } from '@nuxt/schema'
import { createMemoryHistory } from 'vue-router' import { createMemoryHistory } from 'vue-router'
export default <RouterConfig> { export default {
// https://router.vuejs.org/api/interfaces/routeroptions.html // https://router.vuejs.org/api/interfaces/routeroptions.html
history: base => import.meta.client ? createMemoryHistory(base) : null /* default */ history: base => import.meta.client ? createMemoryHistory(base) : null /* default */
} } satisfies RouterConfig
``` ```

View File

@ -4,7 +4,7 @@ description: The <Teleport> component teleports a component to a different locat
--- ---
::warning ::warning
The `to` target of [`<Teleport>`](https://vuejs.org/guide/built-ins/teleport.html) expects a CSS selector string or an actual DOM node. Nuxt currently has SSR support for teleports to `body` only, with client-side support for other targets using a `<ClientOnly>` wrapper. The `to` target of [`<Teleport>`](https://vuejs.org/guide/built-ins/teleport.html) expects a CSS selector string or an actual DOM node. Nuxt currently has SSR support for teleports to `#teleports` only, with client-side support for other targets using a `<ClientOnly>` wrapper.
:: ::
## Body Teleport ## Body Teleport
@ -14,7 +14,7 @@ The `to` target of [`<Teleport>`](https://vuejs.org/guide/built-ins/teleport.htm
<button @click="open = true"> <button @click="open = true">
Open Modal Open Modal
</button> </button>
<Teleport to="body"> <Teleport to="#teleports">
<div v-if="open" class="modal"> <div v-if="open" class="modal">
<p>Hello from the modal!</p> <p>Hello from the modal!</p>
<button @click="open = false"> <button @click="open = false">

View File

@ -52,6 +52,25 @@ const { enabled, state } = usePreviewMode({
The `getState` function will append returned values to current state, so be careful not to accidentally overwrite important state. The `getState` function will append returned values to current state, so be careful not to accidentally overwrite important state.
:: ::
### Customize the `onEnable` and `onDisable` callbacks
By default, when `usePreviewMode` is enabled, it will call `refreshNuxtData()` to re-fetch all data from the server.
When preview mode is disabled, the composable will attach a callback to call `refreshNuxtData()` to run after a subsequent router navigation.
You can specify custom callbacks to be triggered by providing your own functions for the `onEnable` and `onDisable` options.
```js
const { enabled, state } = usePreviewMode({
onEnable: () => {
console.log('preview mode has been enabled')
},
onDisable: () => {
console.log('preview mode has been disabled')
}
})
```
## Example ## Example
The example below creates a page where part of a content is rendered only in preview mode. The example below creates a page where part of a content is rendered only in preview mode.

View File

@ -129,7 +129,7 @@ interface PageMeta {
- **Type**: `boolean | (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean` - **Type**: `boolean | (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean`
Tell Nuxt to scroll to the top before rendering the page or not. If you want to overwrite the default scroll behavior of Nuxt, you can do so in `~/app/router.options.ts` (see [custom routing](/docs/guide/going-further/custom-routing#using-approuteroptions)) for more info. Tell Nuxt to scroll to the top before rendering the page or not. If you want to overwrite the default scroll behavior of Nuxt, you can do so in `~/app/router.options.ts` (see [custom routing](/docs/guide/recipes/custom-routing#using-approuteroptions)) for more info.
**`[key: string]`** **`[key: string]`**

View File

@ -209,6 +209,7 @@ type NuxtMiddleware = {
interface AddRouteMiddlewareOptions { interface AddRouteMiddlewareOptions {
override?: boolean override?: boolean
prepend?: boolean
} }
``` ```
@ -246,7 +247,21 @@ A middleware object or an array of middleware objects with the following propert
**Default**: `{}` **Default**: `{}`
Options to pass to the middleware. If `override` is set to `true`, it will override the existing middleware with the same name. - `override` (optional)
**Type**: `boolean`
**Default**: `false`
If enabled, overrides the existing middleware with the same name.
- `prepend` (optional)
**Type**: `boolean`
**Default**: `false`
If enabled, prepends the middleware to the list of existing middleware.
### Examples ### Examples
@ -272,7 +287,7 @@ export default defineNuxtModule({
name: 'auth', name: 'auth',
path: resolver.resolve('runtime/auth.ts'), path: resolver.resolve('runtime/auth.ts'),
global: true global: true
}) }, { prepend: true })
} }
}) })
``` ```

View File

@ -103,7 +103,7 @@ This feature is not yet supported in Nuxt 3.
## `scrollToTop` ## `scrollToTop`
This feature is not yet supported in Nuxt 3. If you want to overwrite the default scroll behavior of `vue-router`, you can do so in `~/app/router.options.ts` (see [docs](/docs/guide/going-further/custom-routing#router-options)) for more info. This feature is not yet supported in Nuxt 3. If you want to overwrite the default scroll behavior of `vue-router`, you can do so in `~/app/router.options.ts` (see [docs](/docs/guide/recipes/custom-routing#router-options)) for more info.
Similar to `key`, specify it within the [`definePageMeta`](/docs/api/utils/define-page-meta) compiler macro. Similar to `key`, specify it within the [`definePageMeta`](/docs/api/utils/define-page-meta) compiler macro.
```diff [pages/index.vue] ```diff [pages/index.vue]

View File

@ -31,7 +31,7 @@
"test:types": "pnpm --filter './test/fixtures/**' test:types", "test:types": "pnpm --filter './test/fixtures/**' test:types",
"test:unit": "vitest run packages/", "test:unit": "vitest run packages/",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs" "typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs --languages html"
}, },
"resolutions": { "resolutions": {
"@nuxt/kit": "workspace:*", "@nuxt/kit": "workspace:*",
@ -39,7 +39,7 @@
"@nuxt/ui-templates": "workspace:*", "@nuxt/ui-templates": "workspace:*",
"@nuxt/vite-builder": "workspace:*", "@nuxt/vite-builder": "workspace:*",
"@nuxt/webpack-builder": "workspace:*", "@nuxt/webpack-builder": "workspace:*",
"@types/node": "*", "@types/node": "20.14.15",
"c12": "2.0.0-beta.1", "c12": "2.0.0-beta.1",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"jiti": "2.0.0-beta.3", "jiti": "2.0.0-beta.3",
@ -49,18 +49,18 @@
"rollup": "^4.20.0", "rollup": "^4.20.0",
"typescript": "5.5.4", "typescript": "5.5.4",
"unbuild": "3.0.0-rc.7", "unbuild": "3.0.0-rc.7",
"vite": "5.3.5", "vite": "5.4.0",
"vue": "3.4.34" "vue": "3.4.37"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.8.0", "@eslint/js": "9.9.0",
"@nuxt/eslint-config": "0.5.0", "@nuxt/eslint-config": "0.5.0",
"@nuxt/kit": "workspace:*", "@nuxt/kit": "workspace:*",
"@nuxt/test-utils": "3.14.0", "@nuxt/test-utils": "3.14.1",
"@nuxt/webpack-builder": "workspace:*", "@nuxt/webpack-builder": "workspace:*",
"@testing-library/vue": "8.1.0", "@testing-library/vue": "8.1.0",
"@types/eslint__js": "8.42.3", "@types/eslint__js": "8.42.3",
"@types/node": "20.14.14", "@types/node": "20.14.15",
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"@unhead/schema": "1.9.16", "@unhead/schema": "1.9.16",
"@vitejs/plugin-vue": "5.1.2", "@vitejs/plugin-vue": "5.1.2",
@ -70,11 +70,12 @@
"case-police": "0.6.1", "case-police": "0.6.1",
"changelogen": "0.5.5", "changelogen": "0.5.5",
"consola": "3.2.3", "consola": "3.2.3",
"cssnano": "7.0.4", "cssnano": "7.0.5",
"destr": "2.0.3",
"devalue": "5.0.0", "devalue": "5.0.0",
"eslint": "9.8.0", "eslint": "9.9.0",
"eslint-plugin-no-only-tests": "3.1.0", "eslint-plugin-no-only-tests": "3.1.0",
"eslint-plugin-perfectionist": "3.1.2", "eslint-plugin-perfectionist": "3.1.3",
"eslint-typegen": "0.3.0", "eslint-typegen": "0.3.0",
"execa": "9.3.0", "execa": "9.3.0",
"globby": "14.0.2", "globby": "14.0.2",
@ -88,7 +89,7 @@
"nuxt-content-twoslash": "0.1.1", "nuxt-content-twoslash": "0.1.1",
"ofetch": "1.3.4", "ofetch": "1.3.4",
"pathe": "1.1.2", "pathe": "1.1.2",
"playwright-core": "1.45.3", "playwright-core": "1.46.0",
"rimraf": "6.0.1", "rimraf": "6.0.1",
"semver": "7.6.3", "semver": "7.6.3",
"std-env": "3.7.0", "std-env": "3.7.0",
@ -96,11 +97,11 @@
"ufo": "1.5.4", "ufo": "1.5.4",
"vitest": "2.0.5", "vitest": "2.0.5",
"vitest-environment-nuxt": "1.0.0", "vitest-environment-nuxt": "1.0.0",
"vue": "3.4.34", "vue": "3.4.37",
"vue-router": "4.4.2", "vue-router": "4.4.3",
"vue-tsc": "2.0.29" "vue-tsc": "2.0.29"
}, },
"packageManager": "pnpm@9.6.0", "packageManager": "pnpm@9.7.0",
"engines": { "engines": {
"node": "^16.10.0 || >=18.0.0" "node": "^16.10.0 || >=18.0.0"
}, },

View File

@ -34,7 +34,7 @@
"errx": "^0.1.0", "errx": "^0.1.0",
"globby": "^14.0.2", "globby": "^14.0.2",
"hash-sum": "^2.0.0", "hash-sum": "^2.0.0",
"ignore": "^5.3.1", "ignore": "^5.3.2",
"jiti": "^2.0.0-beta.3", "jiti": "^2.0.0-beta.3",
"klona": "^2.0.6", "klona": "^2.0.6",
"mlly": "^1.7.1", "mlly": "^1.7.1",
@ -52,7 +52,7 @@
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda",
"unbuild": "3.0.0-rc.7", "unbuild": "3.0.0-rc.7",
"vite": "5.3.5", "vite": "5.4.0",
"vitest": "2.0.5", "vitest": "2.0.5",
"webpack": "5.93.0" "webpack": "5.93.0"
}, },

View File

@ -35,6 +35,11 @@ export interface AddRouteMiddlewareOptions {
* @default false * @default false
*/ */
override?: boolean override?: boolean
/**
* Prepend middleware to the list
* @default false
*/
prepend?: boolean
} }
export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], options: AddRouteMiddlewareOptions = {}) { export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], options: AddRouteMiddlewareOptions = {}) {
@ -51,6 +56,8 @@ export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], op
} else { } else {
logger.warn(`'${middleware.name}' middleware already exists at '${foundPath}'. You can set \`override: true\` to replace it.`) logger.warn(`'${middleware.name}' middleware already exists at '${foundPath}'. You can set \`override: true\` to replace it.`)
} }
} else if (options.prepend === true) {
app.middleware.unshift({ ...middleware })
} else { } else {
app.middleware.push({ ...middleware }) app.middleware.push({ ...middleware })
} }

View File

@ -68,7 +68,7 @@
"@unhead/dom": "^1.9.16", "@unhead/dom": "^1.9.16",
"@unhead/ssr": "^1.9.16", "@unhead/ssr": "^1.9.16",
"@unhead/vue": "^1.9.16", "@unhead/vue": "^1.9.16",
"@vue/shared": "^3.4.34", "@vue/shared": "^3.4.37",
"acorn": "8.12.1", "acorn": "8.12.1",
"c12": "^2.0.0-beta.1", "c12": "^2.0.0-beta.1",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
@ -85,7 +85,7 @@
"globby": "^14.0.2", "globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"hookable": "^5.5.3", "hookable": "^5.5.3",
"ignore": "^5.3.1", "ignore": "^5.3.2",
"jiti": "^2.0.0-beta.3", "jiti": "^2.0.0-beta.3",
"klona": "^2.0.6", "klona": "^2.0.6",
"knitwork": "^1.1.0", "knitwork": "^1.1.0",
@ -110,24 +110,24 @@
"unctx": "^2.3.1", "unctx": "^2.3.1",
"unenv": "^1.10.0", "unenv": "^1.10.0",
"unimport": "^3.10.0", "unimport": "^3.10.0",
"unplugin": "^1.12.0", "unplugin": "^1.12.1",
"unplugin-vue-router": "^0.10.2", "unplugin-vue-router": "^0.10.3",
"unstorage": "^1.10.2", "unstorage": "^1.10.2",
"untyped": "^1.4.2", "untyped": "^1.4.2",
"vue": "^3.4.34", "vue": "^3.4.37",
"vue-bundle-renderer": "^2.1.0", "vue-bundle-renderer": "^2.1.0",
"vue-devtools-stub": "^0.1.0", "vue-devtools-stub": "^0.1.0",
"vue-router": "^4.4.2" "vue-router": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/scripts": "0.6.5", "@nuxt/scripts": "0.6.6",
"@nuxt/ui-templates": "1.3.4", "@nuxt/ui-templates": "1.3.4",
"@parcel/watcher": "2.4.1", "@parcel/watcher": "2.4.1",
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"@vitejs/plugin-vue": "5.1.2", "@vitejs/plugin-vue": "5.1.2",
"@vue/compiler-sfc": "3.4.34", "@vue/compiler-sfc": "3.4.37",
"unbuild": "3.0.0-rc.7", "unbuild": "3.0.0-rc.7",
"vite": "5.3.5", "vite": "5.4.0",
"vitest": "2.0.5" "vitest": "2.0.5"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -1,5 +1,6 @@
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue' import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue'
import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue' import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue'
import { isPromise } from '@vue/shared'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
import { getFragmentHTML } from './utils' import { getFragmentHTML } from './utils'
@ -42,9 +43,10 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
const clone = { ...component } const clone = { ...component }
if (clone.render) { if (clone.render) {
// override the component render (non script setup component) // override the component render (non script setup component) or dev mode
clone.render = (ctx: any, cache: any, $props: any, $setup: any, $data: any, $options: any) => { clone.render = (ctx: any, cache: any, $props: any, $setup: any, $data: any, $options: any) => {
if ($setup.mounted$ ?? ctx.mounted$) { // import.meta.client for server-side treeshakking
if (import.meta.client && ($setup.mounted$ ?? ctx.mounted$)) {
const res = component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options) const res = component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options)
return (res.children === null || typeof res.children === 'string') return (res.children === null || typeof res.children === 'string')
? cloneVNode(res) ? cloneVNode(res)
@ -63,33 +65,39 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
} }
clone.setup = (props, ctx) => { clone.setup = (props, ctx) => {
const nuxtApp = useNuxtApp()
const mounted$ = ref(import.meta.client && nuxtApp.isHydrating === false)
const instance = getCurrentInstance()! const instance = getCurrentInstance()!
const attrs = { ...instance.attrs } if (import.meta.server || nuxtApp.isHydrating) {
const attrs = { ...instance.attrs }
// remove existing directives during hydration
const directives = extractDirectives(instance)
// prevent attrs inheritance since a staticVNode is rendered before hydration
for (const key in attrs) {
delete instance.attrs[key]
}
// remove existing directives during hydration onMounted(() => {
const directives = extractDirectives(instance) Object.assign(instance.attrs, attrs)
// prevent attrs inheritance since a staticVNode is rendered before hydration instance.vnode.dirs = directives
for (const key in attrs) { })
delete instance.attrs[key]
} }
const mounted$ = ref(false)
onMounted(() => { onMounted(() => {
Object.assign(instance.attrs, attrs)
instance.vnode.dirs = directives
mounted$.value = true mounted$.value = true
}) })
const setupState = component.setup?.(props, ctx) || {}
return Promise.resolve(component.setup?.(props, ctx) || {}) if (isPromise(setupState)) {
.then((setupState) => { return Promise.resolve(setupState).then((setupState) => {
if (typeof setupState !== 'function') { if (typeof setupState !== 'function') {
setupState = setupState || {} setupState = setupState || {}
setupState.mounted$ = mounted$ setupState.mounted$ = mounted$
return setupState return setupState
} }
return (...args: any[]) => { return (...args: any[]) => {
if (mounted$.value) { if (import.meta.client && (mounted$.value || !nuxtApp.isHydrating)) {
const res = setupState(...args) const res = setupState(...args)
return (res.children === null || typeof res.children === 'string') return (res.children === null || typeof res.children === 'string')
? cloneVNode(res) ? cloneVNode(res)
@ -100,6 +108,20 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
} }
} }
}) })
} else {
if (typeof setupState === 'function') {
return (...args: any[]) => {
if (mounted$.value) {
return h(setupState(...args), ctx.attrs)
}
const fragment = getFragmentHTML(instance?.vnode.el ?? null) ?? ['<div></div>']
return import.meta.client
? createStaticVNode(fragment.join(''), fragment.length) :
h('div', ctx.attrs)
}
}
return Object.assign(setupState, { mounted$ })
}
} }
cache.set(component, clone) cache.set(component, clone)

View File

@ -40,6 +40,7 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?:
export function useCookie<T = string | null | undefined> (name: string, _opts: CookieOptions<T> & { readonly: true }): Readonly<CookieRef<T>> export function useCookie<T = string | null | undefined> (name: string, _opts: CookieOptions<T> & { readonly: true }): Readonly<CookieRef<T>>
export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T>): CookieRef<T> { export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T>): CookieRef<T> {
const opts = { ...CookieDefaults, ..._opts } const opts = { ...CookieDefaults, ..._opts }
opts.filter ??= key => key === name
const cookies = readRawCookies(opts) || {} const cookies = readRawCookies(opts) || {}
let delay: number | undefined let delay: number | undefined

View File

@ -152,7 +152,7 @@ export function useFetch<
let controller: AbortController let controller: AbortController
const asyncData = useAsyncData<_ResT, ErrorT, DataT, PickKeys, DefaultT>(key, () => { const asyncData = useAsyncData<_ResT, ErrorT, DataT, PickKeys, DefaultT>(key, () => {
controller?.abort?.() controller?.abort?.('Request aborted as another request to the same endpoint was initiated.')
controller = typeof AbortController !== 'undefined' ? new AbortController() : {} as AbortController controller = typeof AbortController !== 'undefined' ? new AbortController() : {} as AbortController
/** /**
@ -164,7 +164,7 @@ export function useFetch<
const timeoutLength = toValue(opts.timeout) const timeoutLength = toValue(opts.timeout)
let timeoutId: NodeJS.Timeout let timeoutId: NodeJS.Timeout
if (timeoutLength) { if (timeoutLength) {
timeoutId = setTimeout(() => controller.abort(), timeoutLength) timeoutId = setTimeout(() => controller.abort('Request aborted due to timeout.'), timeoutLength)
controller.signal.onabort = () => clearTimeout(timeoutId) controller.signal.onabort = () => clearTimeout(timeoutId)
} }

View File

@ -7,6 +7,8 @@ const SEPARATOR = '-'
/** /**
* Generate an SSR-friendly unique identifier that can be passed to accessibility attributes. * Generate an SSR-friendly unique identifier that can be passed to accessibility attributes.
*
* The generated ID is unique in the context of the current Nuxt instance and key.
*/ */
export function useId (): string export function useId (): string
export function useId (key?: string): string { export function useId (key?: string): string {
@ -24,7 +26,7 @@ export function useId (key?: string): string {
throw new TypeError('[nuxt] `useId` must be called within a component setup function.') throw new TypeError('[nuxt] `useId` must be called within a component setup function.')
} }
nuxtApp._id ||= 0 nuxtApp._genId ||= 0
instance._nuxtIdIndex ||= {} instance._nuxtIdIndex ||= {}
instance._nuxtIdIndex[key] ||= 0 instance._nuxtIdIndex[key] ||= 0
@ -32,7 +34,7 @@ export function useId (key?: string): string {
if (import.meta.server) { if (import.meta.server) {
const ids = JSON.parse(instance.attrs[ATTR_KEY] as string | undefined || '{}') const ids = JSON.parse(instance.attrs[ATTR_KEY] as string | undefined || '{}')
ids[instanceIndex] = key + SEPARATOR + nuxtApp._id++ ids[instanceIndex] = key + SEPARATOR + nuxtApp._genId++
instance.attrs[ATTR_KEY] = JSON.stringify(ids) instance.attrs[ATTR_KEY] = JSON.stringify(ids)
return ids[instanceIndex] return ids[instanceIndex]
} }
@ -54,5 +56,5 @@ export function useId (key?: string): string {
} }
// pure client-side ids, avoiding potential collision with server-side ids // pure client-side ids, avoiding potential collision with server-side ids
return key + '_' + nuxtApp._id++ return key + '_' + nuxtApp._genId++
} }

View File

@ -1,7 +1,7 @@
import { hasProtocol, joinURL, withoutTrailingSlash } from 'ufo' import { hasProtocol, joinURL, withoutTrailingSlash } from 'ufo'
import { parse } from 'devalue' import { parse } from 'devalue'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import { getCurrentInstance, onServerPrefetch } from 'vue' import { getCurrentInstance, onServerPrefetch, reactive } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import type { NuxtPayload } from '../nuxt' import type { NuxtPayload } from '../nuxt'
@ -122,6 +122,10 @@ export async function getNuxtClientPayload () {
...window.__NUXT__, ...window.__NUXT__,
} }
if (payloadCache!.config?.public) {
payloadCache!.config.public = reactive(payloadCache!.config.public)
}
return payloadCache return payloadCache
} }

View File

@ -10,9 +10,31 @@ interface Preview {
_initialized?: boolean _initialized?: boolean
} }
/**
* Options for configuring preview mode.
*/
interface PreviewModeOptions<S> { interface PreviewModeOptions<S> {
/**
* A function that determines whether preview mode should be enabled based on the current state.
* @param {Record<any, unknown>} state - The state of the preview.
* @returns {boolean} A boolean indicating whether the preview mode is enabled.
*/
shouldEnable?: (state: Preview['state']) => boolean shouldEnable?: (state: Preview['state']) => boolean
/**
* A function that retrieves the current state.
* The `getState` function will append returned values to current state, so be careful not to accidentally overwrite important state.
* @param {Record<any, unknown>} state - The preview state.
* @returns {Record<any, unknown>} The preview state.
*/
getState?: (state: Preview['state']) => S getState?: (state: Preview['state']) => S
/**
* A function to be called when the preview mode is enabled.
*/
onEnable?: () => void
/**
* A function to be called when the preview mode is disabled.
*/
onDisable?: () => void
} }
type EnteredState = Record<any, unknown> | null | undefined | void type EnteredState = Record<any, unknown> | null | undefined | void
@ -54,9 +76,10 @@ export function usePreviewMode<S extends EnteredState> (options: PreviewModeOpti
} }
if (import.meta.client && !unregisterRefreshHook) { if (import.meta.client && !unregisterRefreshHook) {
refreshNuxtData() const onEnable = options.onEnable ?? refreshNuxtData
onEnable()
unregisterRefreshHook = useRouter().afterEach(() => refreshNuxtData()) unregisterRefreshHook = options.onDisable ?? useRouter().afterEach(() => refreshNuxtData())
} }
} else if (unregisterRefreshHook) { } else if (unregisterRefreshHook) {
unregisterRefreshHook() unregisterRefreshHook()

View File

@ -18,18 +18,18 @@ export function useScript<T extends Record<string | symbol, any>> (input: UseScr
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useElementScriptTrigger (...args: unknown[]) { export function useScriptTriggerElement (...args: unknown[]) {
renderStubMessage('useElementScriptTrigger') renderStubMessage('useScriptTriggerElement')
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useConsentScriptTrigger (...args: unknown[]) { export function useScriptTriggerConsent (...args: unknown[]) {
renderStubMessage('useConsentScriptTrigger') renderStubMessage('useScriptTriggerConsent')
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useAnalyticsPageEvent (...args: unknown[]) { export function useScriptEventPage (...args: unknown[]) {
renderStubMessage('useAnalyticsPageEvent') renderStubMessage('useScriptEventPage')
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@ -25,8 +25,8 @@ import { appId } from '#build/nuxt.config.mjs'
import type { NuxtAppLiterals } from '#app' import type { NuxtAppLiterals } from '#app'
function getNuxtAppCtx (appName = appId || 'nuxt-app') { function getNuxtAppCtx (id = appId || 'nuxt-app') {
return getContext<NuxtApp>(appName, { return getContext<NuxtApp>(id, {
asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server, asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server,
}) })
} }
@ -98,8 +98,6 @@ export interface NuxtPayload {
} }
interface _NuxtApp { interface _NuxtApp {
/** @internal */
_name: string
vueApp: App<Element> vueApp: App<Element>
versions: Record<string, string> versions: Record<string, string>
@ -113,8 +111,15 @@ interface _NuxtApp {
/** @internal */ /** @internal */
_cookies?: Record<string, unknown> _cookies?: Record<string, unknown>
/** @internal */ /**
_id?: number * The id of the Nuxt application.
* @internal */
_id: string
/**
* The next id that can be used for generating unique ids via `useId`.
* @internal
*/
_genId?: number
/** @internal */ /** @internal */
_scope: EffectScope _scope: EffectScope
/** @internal */ /** @internal */
@ -244,13 +249,17 @@ export type ObjectPluginInput<Injections extends Record<string, unknown> = Recor
export interface CreateOptions { export interface CreateOptions {
vueApp: NuxtApp['vueApp'] vueApp: NuxtApp['vueApp']
ssrContext?: NuxtApp['ssrContext'] ssrContext?: NuxtApp['ssrContext']
/**
* The id of the Nuxt application, overrides the default id specified in the Nuxt config (default: `nuxt-app`).
*/
id?: NuxtApp['_id']
} }
/** @since 3.0.0 */ /** @since 3.0.0 */
export function createNuxtApp (options: CreateOptions) { export function createNuxtApp (options: CreateOptions) {
let hydratingCount = 0 let hydratingCount = 0
const nuxtApp: NuxtApp = { const nuxtApp: NuxtApp = {
_name: appId || 'nuxt-app', _id: options.id || appId || 'nuxt-app',
_scope: effectScope(), _scope: effectScope(),
provide: undefined, provide: undefined,
versions: { versions: {
@ -489,7 +498,7 @@ export function isNuxtPlugin (plugin: unknown) {
*/ */
export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp | _NuxtApp, setup: T, args?: Parameters<T>) { export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp | _NuxtApp, setup: T, args?: Parameters<T>) {
const fn: () => ReturnType<T> = () => args ? setup(...args as Parameters<T>) : setup() const fn: () => ReturnType<T> = () => args ? setup(...args as Parameters<T>) : setup()
const nuxtAppCtx = getNuxtAppCtx(nuxt._name) const nuxtAppCtx = getNuxtAppCtx(nuxt._id)
if (import.meta.server) { if (import.meta.server) {
return nuxt.vueApp.runWithContext(() => nuxtAppCtx.callAsync(nuxt as NuxtApp, fn)) return nuxt.vueApp.runWithContext(() => nuxtAppCtx.callAsync(nuxt as NuxtApp, fn))
} else { } else {
@ -507,13 +516,13 @@ export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp |
* @since 3.10.0 * @since 3.10.0
*/ */
export function tryUseNuxtApp (): NuxtApp | null export function tryUseNuxtApp (): NuxtApp | null
export function tryUseNuxtApp (appName?: string): NuxtApp | null { export function tryUseNuxtApp (id?: string): NuxtApp | null {
let nuxtAppInstance let nuxtAppInstance
if (hasInjectionContext()) { if (hasInjectionContext()) {
nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt
} }
nuxtAppInstance = nuxtAppInstance || getNuxtAppCtx(appName).tryUse() nuxtAppInstance = nuxtAppInstance || getNuxtAppCtx(id).tryUse()
return nuxtAppInstance || null return nuxtAppInstance || null
} }
@ -526,9 +535,9 @@ export function tryUseNuxtApp (appName?: string): NuxtApp | null {
* @since 3.0.0 * @since 3.0.0
*/ */
export function useNuxtApp (): NuxtApp export function useNuxtApp (): NuxtApp
export function useNuxtApp (appName?: string): NuxtApp { export function useNuxtApp (id?: string): NuxtApp {
// @ts-expect-error internal usage of appName // @ts-expect-error internal usage of id
const nuxtAppInstance = tryUseNuxtApp(appName) const nuxtAppInstance = tryUseNuxtApp(id)
if (!nuxtAppInstance) { if (!nuxtAppInstance) {
if (import.meta.dev) { if (import.meta.dev) {

View File

@ -51,3 +51,23 @@ declare module 'vue' {
head?(nuxtApp: NuxtApp): UseHeadInput head?(nuxtApp: NuxtApp): UseHeadInput
} }
} }
declare module '@vue/runtime-core' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface App<HostElement> {
$nuxt: NuxtApp
}
interface ComponentCustomProperties {
$nuxt: NuxtApp
}
}
declare module '@vue/runtime-dom' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface App<HostElement> {
$nuxt: NuxtApp
}
interface ComponentCustomProperties {
$nuxt: NuxtApp
}
}

View File

@ -660,7 +660,8 @@ function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandRespons
const response: NuxtIslandResponse['components'] = {} const response: NuxtIslandResponse['components'] = {}
for (const clientUid in ssrContext.islandContext.components) { for (const clientUid in ssrContext.islandContext.components) {
const html = ssrContext.teleports?.[clientUid] || '' // remove teleport anchor to avoid hydration issues
const html = ssrContext.teleports?.[clientUid].replaceAll('<!--teleport start anchor-->', '') || ''
response[clientUid] = { response[clientUid] = {
...ssrContext.islandContext.components[clientUid], ...ssrContext.islandContext.components[clientUid],
html, html,

View File

@ -1,6 +1,6 @@
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { genArrayFromRaw, genDynamicImport, genExport, genImport, genObjectFromRawEntries, genSafeVariableName, genString } from 'knitwork' import { genArrayFromRaw, genDynamicImport, genExport, genImport, genObjectFromRawEntries, genSafeVariableName, genString } from 'knitwork'
import { isAbsolute, join, relative, resolve } from 'pathe' import { join, relative, resolve } from 'pathe'
import type { JSValue } from 'untyped' import type { JSValue } from 'untyped'
import { generateTypes, resolveSchema } from 'untyped' import { generateTypes, resolveSchema } from 'untyped'
import escapeRE from 'escape-string-regexp' import escapeRE from 'escape-string-regexp'
@ -98,19 +98,36 @@ export const serverPluginTemplate: NuxtTemplate = {
export const pluginsDeclaration: NuxtTemplate = { export const pluginsDeclaration: NuxtTemplate = {
filename: 'types/plugins.d.ts', filename: 'types/plugins.d.ts',
getContents: async (ctx) => { getContents: async ({ nuxt, app }) => {
const EXTENSION_RE = new RegExp(`(?<=\\w)(${ctx.nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g') const EXTENSION_RE = new RegExp(`(?<=\\w)(${nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g')
const typesDir = join(nuxt.options.buildDir, 'types')
const tsImports: string[] = [] const tsImports: string[] = []
for (const p of ctx.app.plugins) { const pluginNames: string[] = []
const sources = [p.src, p.src.replace(EXTENSION_RE, '.d.ts')]
if (!isAbsolute(p.src)) { function exists (path: string) {
tsImports.push(p.src.replace(EXTENSION_RE, '')) return app.templates.some(t => t.write && path === t.dst) || existsSync(path)
} else if (ctx.app.templates.some(t => t.write && t.dst && sources.includes(t.dst)) || sources.some(s => existsSync(s))) {
tsImports.push(relative(join(ctx.nuxt.options.buildDir, 'types'), p.src).replace(EXTENSION_RE, ''))
}
} }
const pluginsName = (await annotatePlugins(ctx.nuxt, ctx.app.plugins)).filter(p => p.name).map(p => `'${p.name}'`) for (const plugin of await annotatePlugins(nuxt, app.plugins)) {
if (plugin.name) {
pluginNames.push(`'${plugin.name}'`)
}
const pluginPath = resolve(typesDir, plugin.src)
const relativePath = relative(typesDir, pluginPath)
const correspondingDeclaration = pluginPath.replace(/\.(?<letter>[cm])?jsx?$/, '.d.$<letter>ts')
if (correspondingDeclaration !== pluginPath && exists(correspondingDeclaration)) {
tsImports.push(relativePath)
continue
}
if (exists(pluginPath)) {
tsImports.push(relativePath.replace(EXTENSION_RE, ''))
continue
}
}
return `// Generated by Nuxt' return `// Generated by Nuxt'
import type { Plugin } from '#app' import type { Plugin } from '#app'
@ -126,10 +143,18 @@ declare module '#app' {
interface NuxtApp extends NuxtAppInjections { } interface NuxtApp extends NuxtAppInjections { }
interface NuxtAppLiterals { interface NuxtAppLiterals {
pluginName: ${pluginsName.join(' | ')} pluginName: ${pluginNames.join(' | ')}
} }
} }
declare module '@vue/runtime-core' {
interface ComponentCustomProperties extends NuxtAppInjections { }
}
declare module '@vue/runtime-dom' {
interface ComponentCustomProperties extends NuxtAppInjections { }
}
declare module 'vue' { declare module 'vue' {
interface ComponentCustomProperties extends NuxtAppInjections { } interface ComponentCustomProperties extends NuxtAppInjections { }
} }
@ -143,36 +168,74 @@ const adHocModules = ['router', 'pages', 'imports', 'meta', 'components', 'nuxt-
export const schemaTemplate: NuxtTemplate = { export const schemaTemplate: NuxtTemplate = {
filename: 'types/schema.d.ts', filename: 'types/schema.d.ts',
getContents: async ({ nuxt }) => { getContents: async ({ nuxt }) => {
const moduleInfo = nuxt.options._installedModules.map(m => ({
...m.meta,
importName: m.entryPath || m.meta?.name,
})).filter(m => m.configKey && m.name && !adHocModules.includes(m.name))
const relativeRoot = relative(resolve(nuxt.options.buildDir, 'types'), nuxt.options.rootDir) const relativeRoot = relative(resolve(nuxt.options.buildDir, 'types'), nuxt.options.rootDir)
const getImportName = (name: string) => (name[0] === '.' ? './' + join(relativeRoot, name) : name).replace(/\.\w+$/, '') const getImportName = (name: string) => (name[0] === '.' ? './' + join(relativeRoot, name) : name).replace(/\.\w+$/, '')
const modules = moduleInfo.map(meta => [genString(meta.configKey), getImportName(meta.importName), meta])
const modules = nuxt.options._installedModules
.filter(m => m.meta && m.meta.configKey && m.meta.name && !adHocModules.includes(m.meta.name))
.map(m => [genString(m.meta.configKey), getImportName(m.entryPath || m.meta.name), m] as const)
const privateRuntimeConfig = Object.create(null) const privateRuntimeConfig = Object.create(null)
for (const key in nuxt.options.runtimeConfig) { for (const key in nuxt.options.runtimeConfig) {
if (key !== 'public') { if (key !== 'public') {
privateRuntimeConfig[key] = nuxt.options.runtimeConfig[key] privateRuntimeConfig[key] = nuxt.options.runtimeConfig[key]
} }
} }
const moduleOptionsInterface = [
...modules.map(([configKey, importName]) => const moduleOptionsInterface = (jsdocTags: boolean) => [
` [${configKey}]?: typeof ${genDynamicImport(importName, { wrapper: false })}.default extends NuxtModule<infer O> ? Partial<O> : Record<string, any>`, ...modules.flatMap(([configKey, importName, mod]) => {
), let link: string | undefined
modules.length > 0 ? ` modules?: (undefined | null | false | NuxtModule | string | [NuxtModule | string, Record<string, any>] | ${modules.map(([configKey, importName, meta]) => `[${genString(meta?.rawPath || importName)}, Exclude<NuxtConfig[${configKey}], boolean>]`).join(' | ')})[],` : '',
] // If it's not a local module, provide a link based on its name
if (!mod.meta?.rawPath) {
link = `https://www.npmjs.com/package/${importName}`
}
if (typeof mod.meta?.docs === 'string') {
link = mod.meta.docs
} else if (mod.meta?.repository) {
if (typeof mod.meta.repository === 'string') {
link = mod.meta.repository
} else if (typeof mod.meta.repository.url === 'string') {
link = mod.meta.repository.url
}
if (link) {
if (link.startsWith('git+')) {
link = link.replace(/^git\+/, '')
}
if (!link.startsWith('http')) {
link = 'https://github.com/' + link
}
}
}
return [
` /**`,
` * Configuration for \`${importName}\``,
...jsdocTags && link
? [
` * @see ${link}`,
]
: [],
` */`,
` [${configKey}]?: typeof ${genDynamicImport(importName, { wrapper: false })}.default extends NuxtModule<infer O> ? Partial<O> : Record<string, any>`,
]
}),
modules.length > 0 ? ` modules?: (undefined | null | false | NuxtModule | string | [NuxtModule | string, Record<string, any>] | ${modules.map(([configKey, importName, mod]) => `[${genString(mod.meta?.rawPath || importName)}, Exclude<NuxtConfig[${configKey}], boolean>]`).join(' | ')})[],` : '',
].filter(Boolean)
return [ return [
'import { NuxtModule, RuntimeConfig } from \'@nuxt/schema\'', 'import { NuxtModule, RuntimeConfig } from \'@nuxt/schema\'',
'declare module \'@nuxt/schema\' {', 'declare module \'@nuxt/schema\' {',
' interface NuxtConfig {', ' interface NuxtConfig {',
moduleOptionsInterface, // TypeScript will duplicate the jsdoc tags if we augment it twice
// So here we only generate tags for `nuxt/schema`
...moduleOptionsInterface(false),
' }', ' }',
'}', '}',
'declare module \'nuxt/schema\' {', 'declare module \'nuxt/schema\' {',
' interface NuxtConfig {', ' interface NuxtConfig {',
moduleOptionsInterface, ...moduleOptionsInterface(true),
' }', ' }',
generateTypes(await resolveSchema(privateRuntimeConfig as Record<string, JSValue>), generateTypes(await resolveSchema(privateRuntimeConfig as Record<string, JSValue>),
{ {

View File

@ -1,5 +1,5 @@
export { getNameFromPath, hasSuffix, resolveComponentNameSegments } from './names' export { getNameFromPath, hasSuffix, resolveComponentNameSegments } from './names'
export { isJS, isVue } from './plugins' export { getLoader, isJS, isVue } from './plugins'
export function uniqueBy<T, K extends keyof T> (arr: T[], key: K) { export function uniqueBy<T, K extends keyof T> (arr: T[], key: K) {
if (arr.length < 2) { if (arr.length < 2) {

View File

@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import { extname } from 'pathe'
import { parseQuery, parseURL } from 'ufo' import { parseQuery, parseURL } from 'ufo'
export function isVue (id: string, opts: { type?: Array<'template' | 'script' | 'style'> } = {}) { export function isVue (id: string, opts: { type?: Array<'template' | 'script' | 'style'> } = {}) {
@ -41,3 +42,15 @@ export function isJS (id: string) {
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
return JS_RE.test(pathname) return JS_RE.test(pathname)
} }
export function getLoader (id: string): 'vue' | 'ts' | 'tsx' | null {
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
const ext = extname(pathname)
if (ext === '.vue') {
return 'vue'
}
if (!JS_RE.test(ext)) {
return null
}
return ext.endsWith('x') ? 'tsx' : 'ts'
}

View File

@ -117,9 +117,9 @@ const granularAppPresets: InlinePreset[] = [
export const scriptsStubsPreset = { export const scriptsStubsPreset = {
imports: [ imports: [
'useConsentScriptTrigger', 'useScriptTriggerConsent',
'useAnalyticsPageEvent', 'useScriptEventPage',
'useElementScriptTrigger', 'useScriptTriggerElement',
'useScript', 'useScript',
'useScriptGoogleAnalytics', 'useScriptGoogleAnalytics',
'useScriptPlausibleAnalytics', 'useScriptPlausibleAnalytics',

View File

@ -58,7 +58,7 @@ function _getHashElementScrollMarginTop (selector: string): number {
try { try {
const elem = document.querySelector(selector) const elem = document.querySelector(selector)
if (elem) { if (elem) {
return Number.parseFloat(getComputedStyle(elem).scrollMarginTop) + Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) return (Number.parseFloat(getComputedStyle(elem).scrollMarginTop) || 0) + (Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0)
} }
} catch { } catch {
// ignore any errors parsing scrollMarginTop // ignore any errors parsing scrollMarginTop

View File

@ -13,7 +13,7 @@ import { walk } from 'estree-walker'
import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree' import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree'
import type { NuxtPage } from 'nuxt/schema' import type { NuxtPage } from 'nuxt/schema'
import { uniqueBy } from '../core/utils' import { getLoader, uniqueBy } from '../core/utils'
import { toArray } from '../utils' import { toArray } from '../utils'
import { distDir } from '../dirs' import { distDir } from '../dirs'
@ -23,6 +23,7 @@ enum SegmentParserState {
dynamic, dynamic,
optional, optional,
catchall, catchall,
group,
} }
enum SegmentTokenType { enum SegmentTokenType {
@ -30,6 +31,7 @@ enum SegmentTokenType {
dynamic, dynamic,
optional, optional,
catchall, catchall,
group,
} }
interface SegmentToken { interface SegmentToken {
@ -115,7 +117,13 @@ export function generateRoutesFromFiles (files: ScannedFile[], options: Generate
const segment = segments[i] const segment = segments[i]
const tokens = parseSegment(segment) const tokens = parseSegment(segment)
const segmentName = tokens.map(({ value }) => value).join('')
// Skip group segments
if (tokens.every(token => token.type === SegmentTokenType.group)) {
continue
}
const segmentName = tokens.map(({ value, type }) => type === SegmentTokenType.group ? '' : value).join('')
// ex: parent/[slug].vue -> parent-slug // ex: parent/[slug].vue -> parent-slug
route.name += (route.name && '/') + segmentName route.name += (route.name && '/') + segmentName
@ -188,7 +196,8 @@ export async function getRouteMeta (contents: string, absolutePath: string): Pro
if (absolutePath in metaCache) { return metaCache[absolutePath] } if (absolutePath in metaCache) { return metaCache[absolutePath] }
const script = extractScriptContent(contents) const loader = getLoader(absolutePath)
const script = !loader ? null : loader === 'vue' ? extractScriptContent(contents) : { code: contents, loader }
if (!script) { if (!script) {
metaCache[absolutePath] = {} metaCache[absolutePath] = {}
return {} return {}
@ -297,7 +306,9 @@ function getRoutePath (tokens: SegmentToken[]): string {
? `:${token.value}()` ? `:${token.value}()`
: token.type === SegmentTokenType.catchall : token.type === SegmentTokenType.catchall
? `:${token.value}(.*)*` ? `:${token.value}(.*)*`
: encodePath(token.value).replace(/:/g, '\\:')) : token.type === SegmentTokenType.group
? ''
: encodePath(token.value).replace(/:/g, '\\:'))
) )
}, '/') }, '/')
} }
@ -327,7 +338,9 @@ function parseSegment (segment: string) {
? SegmentTokenType.dynamic ? SegmentTokenType.dynamic
: state === SegmentParserState.optional : state === SegmentParserState.optional
? SegmentTokenType.optional ? SegmentTokenType.optional
: SegmentTokenType.catchall, : state === SegmentParserState.catchall
? SegmentTokenType.catchall
: SegmentTokenType.group,
value: buffer, value: buffer,
}) })
@ -342,6 +355,8 @@ function parseSegment (segment: string) {
buffer = '' buffer = ''
if (c === '[') { if (c === '[') {
state = SegmentParserState.dynamic state = SegmentParserState.dynamic
} else if (c === '(') {
state = SegmentParserState.group
} else { } else {
i-- i--
state = SegmentParserState.static state = SegmentParserState.static
@ -352,6 +367,9 @@ function parseSegment (segment: string) {
if (c === '[') { if (c === '[') {
consumeBuffer() consumeBuffer()
state = SegmentParserState.dynamic state = SegmentParserState.dynamic
} else if (c === '(') {
consumeBuffer()
state = SegmentParserState.group
} else { } else {
buffer += c buffer += c
} }
@ -360,6 +378,7 @@ function parseSegment (segment: string) {
case SegmentParserState.catchall: case SegmentParserState.catchall:
case SegmentParserState.dynamic: case SegmentParserState.dynamic:
case SegmentParserState.optional: case SegmentParserState.optional:
case SegmentParserState.group:
if (buffer === '...') { if (buffer === '...') {
buffer = '' buffer = ''
state = SegmentParserState.catchall state = SegmentParserState.catchall
@ -374,10 +393,16 @@ function parseSegment (segment: string) {
consumeBuffer() consumeBuffer()
} }
state = SegmentParserState.initial state = SegmentParserState.initial
} else if (c === ')' && state === SegmentParserState.group) {
if (!buffer) {
throw new Error('Empty group')
} else {
consumeBuffer()
}
state = SegmentParserState.initial
} else if (PARAM_CHAR_RE.test(c)) { } else if (PARAM_CHAR_RE.test(c)) {
buffer += c buffer += c
} else { } else {
// console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`) // console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`)
} }
break break

View File

@ -339,6 +339,34 @@
"redirect": "mockMeta?.redirect", "redirect": "mockMeta?.redirect",
}, },
], ],
"should handle route groups": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/(foo)/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/(bar)/about/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "about"",
"path": "mockMeta?.path ?? """,
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/(foo)/about.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? undefined",
"path": "mockMeta?.path ?? "/about"",
"redirect": "mockMeta?.redirect",
},
],
"should handle trailing slashes with index routes": [ "should handle trailing slashes with index routes": [
{ {
"alias": "mockMeta?.alias || []", "alias": "mockMeta?.alias || []",

View File

@ -234,6 +234,25 @@
"path": ""/parent"", "path": ""/parent"",
}, },
], ],
"should handle route groups": [
{
"component": "() => import("pages/(foo)/index.vue").then(m => m.default || m)",
"name": ""index"",
"path": ""/"",
},
{
"children": [
{
"component": "() => import("pages/(bar)/about/index.vue").then(m => m.default || m)",
"name": ""about"",
"path": """",
},
],
"component": "() => import("pages/(foo)/about.vue").then(m => m.default || m)",
"name": "mockMeta?.name",
"path": ""/about"",
},
],
"should handle trailing slashes with index routes": [ "should handle trailing slashes with index routes": [
{ {
"children": [ "children": [

View File

@ -200,9 +200,9 @@ describe('imports:nuxt/scripts', () => {
const scripts = scriptRegistry().map(s => s.import?.name).filter(Boolean) const scripts = scriptRegistry().map(s => s.import?.name).filter(Boolean)
const globalScripts = new Set([ const globalScripts = new Set([
'useScript', 'useScript',
'useAnalyticsPageEvent', 'useScriptEventPage',
'useElementScriptTrigger', 'useScriptTriggerElement',
'useConsentScriptTrigger', 'useScriptTriggerConsent',
// registered separately // registered separately
'useScriptGoogleTagManager', 'useScriptGoogleTagManager',
'useScriptGoogleAnalytics', 'useScriptGoogleAnalytics',

View File

@ -10,6 +10,16 @@ describe('page metadata', () => {
expect(await getRouteMeta('<template><div>Hi</div></template>', filePath)).toEqual({}) expect(await getRouteMeta('<template><div>Hi</div></template>', filePath)).toEqual({})
}) })
it('should extract metadata from JS/JSX files', async () => {
const fileContents = `definePageMeta({ name: 'bar' })`
for (const ext of ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs']) {
const meta = await getRouteMeta(fileContents, `/app/pages/index.${ext}`)
expect(meta).toStrictEqual({
name: 'bar',
})
}
})
it('should use and invalidate cache', async () => { it('should use and invalidate cache', async () => {
const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>` const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>`
const meta = await getRouteMeta(fileContents, filePath) const meta = await getRouteMeta(fileContents, filePath)

View File

@ -601,6 +601,37 @@ describe('pages:generateRoutesFromFiles', () => {
}, },
], ],
}, },
{
description: 'should handle route groups',
files: [
{ path: `${pagesDir}/(foo)/index.vue` },
{ path: `${pagesDir}/(foo)/about.vue` },
{ path: `${pagesDir}/(bar)/about/index.vue` },
],
output: [
{
name: 'index',
path: '/',
file: `${pagesDir}/(foo)/index.vue`,
meta: undefined,
children: [],
},
{
path: '/about',
file: `${pagesDir}/(foo)/about.vue`,
meta: undefined,
children: [
{
name: 'about',
path: '',
file: `${pagesDir}/(bar)/about/index.vue`,
children: [],
},
],
},
],
},
] ]
const normalizedResults: Record<string, any> = {} const normalizedResults: Record<string, any> = {}

View File

@ -42,23 +42,23 @@
"@unhead/schema": "1.9.16", "@unhead/schema": "1.9.16",
"@vitejs/plugin-vue": "5.1.2", "@vitejs/plugin-vue": "5.1.2",
"@vitejs/plugin-vue-jsx": "4.0.0", "@vitejs/plugin-vue-jsx": "4.0.0",
"@vue/compiler-core": "3.4.34", "@vue/compiler-core": "3.4.37",
"@vue/compiler-sfc": "3.4.34", "@vue/compiler-sfc": "3.4.37",
"@vue/language-core": "2.0.29", "@vue/language-core": "2.0.29",
"c12": "2.0.0-beta.1", "c12": "2.0.0-beta.1",
"esbuild-loader": "4.2.2", "esbuild-loader": "4.2.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"ignore": "5.3.1", "ignore": "5.3.2",
"nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda",
"ofetch": "1.3.4", "ofetch": "1.3.4",
"unbuild": "3.0.0-rc.7", "unbuild": "3.0.0-rc.7",
"unctx": "2.3.1", "unctx": "2.3.1",
"unenv": "1.10.0", "unenv": "1.10.0",
"vite": "5.3.5", "vite": "5.4.0",
"vue": "3.4.34", "vue": "3.4.37",
"vue-bundle-renderer": "2.1.0", "vue-bundle-renderer": "2.1.0",
"vue-loader": "17.4.2", "vue-loader": "17.4.2",
"vue-router": "4.4.2", "vue-router": "4.4.3",
"webpack": "5.93.0", "webpack": "5.93.0",
"webpack-dev-middleware": "7.3.0" "webpack-dev-middleware": "7.3.0"
}, },

View File

@ -179,7 +179,9 @@ export default defineUntypedSchema({
}, },
/** /**
* For multi-app projects, the unique name of the Nuxt application. * For multi-app projects, the unique id of the Nuxt application.
*
* Defaults to `nuxt-app`.
*/ */
appId: { appId: {
$resolve: (val: string) => val ?? 'nuxt-app', $resolve: (val: string) => val ?? 'nuxt-app',

View File

@ -19,7 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/html-minifier": "4.0.5", "@types/html-minifier": "4.0.5",
"@unocss/reset": "0.61.9", "@unocss/reset": "0.62.1",
"critters": "0.0.24", "critters": "0.0.24",
"execa": "9.3.0", "execa": "9.3.0",
"globby": "14.0.2", "globby": "14.0.2",
@ -30,7 +30,7 @@
"pathe": "1.1.2", "pathe": "1.1.2",
"prettier": "3.3.3", "prettier": "3.3.3",
"scule": "1.3.0", "scule": "1.3.0",
"unocss": "0.61.9", "unocss": "0.62.1",
"vite": "5.3.5" "vite": "5.4.0"
} }
} }

View File

@ -29,7 +29,7 @@
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"rollup": "4.20.0", "rollup": "4.20.0",
"unbuild": "3.0.0-rc.7", "unbuild": "3.0.0-rc.7",
"vue": "3.4.34" "vue": "3.4.37"
}, },
"dependencies": { "dependencies": {
"@nuxt/kit": "workspace:*", "@nuxt/kit": "workspace:*",
@ -39,7 +39,7 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"clear": "^0.1.0", "clear": "^0.1.0",
"consola": "^3.2.3", "consola": "^3.2.3",
"cssnano": "^7.0.4", "cssnano": "^7.0.5",
"defu": "^6.1.4", "defu": "^6.1.4",
"esbuild": "^0.23.0", "esbuild": "^0.23.0",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
@ -55,14 +55,14 @@
"pathe": "^1.1.2", "pathe": "^1.1.2",
"perfect-debounce": "^1.0.0", "perfect-debounce": "^1.0.0",
"pkg-types": "^1.1.3", "pkg-types": "^1.1.3",
"postcss": "^8.4.40", "postcss": "^8.4.41",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"std-env": "^3.7.0", "std-env": "^3.7.0",
"strip-literal": "^2.1.0", "strip-literal": "^2.1.0",
"ufo": "^1.5.4", "ufo": "^1.5.4",
"unenv": "^1.10.0", "unenv": "^1.10.0",
"unplugin": "^1.12.0", "unplugin": "^1.12.1",
"vite": "^5.3.5", "vite": "^5.4.0",
"vite-node": "^2.0.5", "vite-node": "^2.0.5",
"vite-plugin-checker": "^0.7.2", "vite-plugin-checker": "^0.7.2",
"vue-bundle-renderer": "^2.1.0" "vue-bundle-renderer": "^2.1.0"

View File

@ -8,12 +8,29 @@ import { normalizeViteManifest } from 'vue-bundle-renderer'
import type { ViteBuildContext } from './vite' import type { ViteBuildContext } from './vite'
export async function writeManifest (ctx: ViteBuildContext) { export async function writeManifest (ctx: ViteBuildContext) {
// This is only used for ssr: false - when ssr is enabled we use vite-node runtime manifest
const devClientManifest = {
'@vite/client': {
isEntry: true,
file: '@vite/client',
css: [],
module: true,
resourceType: 'script',
},
[ctx.entry]: {
isEntry: true,
file: ctx.entry,
module: true,
resourceType: 'script',
},
}
// Write client manifest for use in vue-bundle-renderer // Write client manifest for use in vue-bundle-renderer
const clientDist = resolve(ctx.nuxt.options.buildDir, 'dist/client') const clientDist = resolve(ctx.nuxt.options.buildDir, 'dist/client')
const serverDist = resolve(ctx.nuxt.options.buildDir, 'dist/server') const serverDist = resolve(ctx.nuxt.options.buildDir, 'dist/server')
const manifestFile = resolve(clientDist, 'manifest.json') const manifestFile = resolve(clientDist, 'manifest.json')
const clientManifest = JSON.parse(readFileSync(manifestFile, 'utf-8')) const clientManifest = ctx.nuxt.options.dev ? devClientManifest : JSON.parse(readFileSync(manifestFile, 'utf-8'))
const buildAssetsDir = withTrailingSlash(withoutLeadingSlash(ctx.nuxt.options.app.buildAssetsDir)) const buildAssetsDir = withTrailingSlash(withoutLeadingSlash(ctx.nuxt.options.app.buildAssetsDir))
const BASE_RE = new RegExp(`^${escapeRE(buildAssetsDir)}`) const BASE_RE = new RegExp(`^${escapeRE(buildAssetsDir)}`)

View File

@ -1,77 +1,112 @@
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { useNitro } from '@nuxt/kit' import { useNitro } from '@nuxt/kit'
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import type { UnpluginOptions } from 'unplugin'
import { withLeadingSlash, withTrailingSlash } from 'ufo' import { withLeadingSlash, withTrailingSlash } from 'ufo'
import { dirname, relative } from 'pathe' import { dirname, relative } from 'pathe'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { isCSSRequest } from 'vite'
const PREFIX = 'virtual:public?' const PREFIX = 'virtual:public?'
const CSS_URL_RE = /url\((\/[^)]+)\)/g const CSS_URL_RE = /url\((\/[^)]+)\)/g
const CSS_URL_SINGLE_RE = /url\(\/[^)]+\)/
export const VitePublicDirsPlugin = createUnplugin((options: { sourcemap?: boolean }) => { interface VitePublicDirsPluginOptions {
dev?: boolean
sourcemap?: boolean
baseURL?: string
}
export const VitePublicDirsPlugin = createUnplugin((options: VitePublicDirsPluginOptions) => {
const { resolveFromPublicAssets } = useResolveFromPublicAssets() const { resolveFromPublicAssets } = useResolveFromPublicAssets()
return { const devTransformPlugin: UnpluginOptions = {
name: 'nuxt:vite-public-dir-resolution', name: 'nuxt:vite-public-dir-resolution-dev',
vite: { vite: {
load: { transform (code, id) {
enforce: 'pre', if (!isCSSRequest(id) || !CSS_URL_SINGLE_RE.test(code)) { return }
handler (id) {
if (id.startsWith(PREFIX)) {
return `import { publicAssetsURL } from '#internal/nuxt/paths';export default publicAssetsURL(${JSON.stringify(decodeURIComponent(id.slice(PREFIX.length)))})`
}
},
},
resolveId: {
enforce: 'post',
handler (id) {
if (id === '/__skip_vite' || id[0] !== '/' || id.startsWith('/@fs')) { return }
if (resolveFromPublicAssets(id)) {
return PREFIX + encodeURIComponent(id)
}
},
},
renderChunk (code, chunk) {
if (!chunk.facadeModuleId?.includes('?inline&used')) { return }
const s = new MagicString(code) const s = new MagicString(code)
const q = code.match(/(?<= = )['"`]/)?.[0] || '"'
for (const [full, url] of code.matchAll(CSS_URL_RE)) { for (const [full, url] of code.matchAll(CSS_URL_RE)) {
if (url && resolveFromPublicAssets(url)) { if (url && resolveFromPublicAssets(url)) {
s.replace(full, `url(${q} + publicAssetsURL(${q}${url}${q}) + ${q})`) s.replace(full, `url(${options.baseURL}${url})`)
} }
} }
if (s.hasChanged()) { if (s.hasChanged()) {
s.prepend(`import { publicAssetsURL } from '#internal/nuxt/paths';`)
return { return {
code: s.toString(), code: s.toString(),
map: options.sourcemap ? s.generateMap({ hires: true }) : undefined, map: options.sourcemap ? s.generateMap({ hires: true }) : undefined,
} }
} }
}, },
generateBundle (_outputOptions, bundle) {
for (const file in bundle) {
const chunk = bundle[file]!
if (!file.endsWith('.css') || chunk.type !== 'asset') { continue }
let css = chunk.source.toString()
let wasReplaced = false
for (const [full, url] of css.matchAll(CSS_URL_RE)) {
if (url && resolveFromPublicAssets(url)) {
const relativeURL = relative(withLeadingSlash(dirname(file)), url)
css = css.replace(full, `url(${relativeURL})`)
wasReplaced = true
}
}
if (wasReplaced) {
chunk.source = css
}
}
},
}, },
} }
return [
...(options.dev && options.baseURL && options.baseURL !== '/' ? [devTransformPlugin] : []),
{
name: 'nuxt:vite-public-dir-resolution',
vite: {
load: {
enforce: 'pre',
handler (id) {
if (id.startsWith(PREFIX)) {
return `import { publicAssetsURL } from '#internal/nuxt/paths';export default publicAssetsURL(${JSON.stringify(decodeURIComponent(id.slice(PREFIX.length)))})`
}
},
},
resolveId: {
enforce: 'post',
handler (id) {
if (id === '/__skip_vite' || id[0] !== '/' || id.startsWith('/@fs')) { return }
if (resolveFromPublicAssets(id)) {
return PREFIX + encodeURIComponent(id)
}
},
},
renderChunk (code, chunk) {
if (!chunk.facadeModuleId?.includes('?inline&used')) { return }
const s = new MagicString(code)
const q = code.match(/(?<= = )['"`]/)?.[0] || '"'
for (const [full, url] of code.matchAll(CSS_URL_RE)) {
if (url && resolveFromPublicAssets(url)) {
s.replace(full, `url(${q} + publicAssetsURL(${q}${url}${q}) + ${q})`)
}
}
if (s.hasChanged()) {
s.prepend(`import { publicAssetsURL } from '#internal/nuxt/paths';`)
return {
code: s.toString(),
map: options.sourcemap ? s.generateMap({ hires: true }) : undefined,
}
}
},
generateBundle (_outputOptions, bundle) {
for (const file in bundle) {
const chunk = bundle[file]!
if (!file.endsWith('.css') || chunk.type !== 'asset') { continue }
let css = chunk.source.toString()
let wasReplaced = false
for (const [full, url] of css.matchAll(CSS_URL_RE)) {
if (url && resolveFromPublicAssets(url)) {
const relativeURL = relative(withLeadingSlash(dirname(file)), url)
css = css.replace(full, `url(${relativeURL})`)
wasReplaced = true
}
}
if (wasReplaced) {
chunk.source = css
}
}
},
},
},
]
}) })
export function useResolveFromPublicAssets () { export function useResolveFromPublicAssets () {

View File

@ -85,6 +85,7 @@ export async function buildServer (ctx: ViteBuildContext) {
entryFileNames: '[name].mjs', entryFileNames: '[name].mjs',
format: 'module', format: 'module',
generatedCode: { generatedCode: {
symbols: true, // temporary fix for https://github.com/vuejs/core/issues/8351,
constBindings: true, constBindings: true,
}, },
}, },
@ -148,6 +149,7 @@ export async function buildServer (ctx: ViteBuildContext) {
} }
if (!ctx.nuxt.options.ssr) { if (!ctx.nuxt.options.ssr) {
await writeManifest(ctx)
await onBuild() await onBuild()
return return
} }

View File

@ -126,7 +126,7 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set<string> = ne
app.use('/module', defineLazyEventHandler(() => { app.use('/module', defineLazyEventHandler(() => {
const viteServer = ctx.ssrServer! const viteServer = ctx.ssrServer!
const node: ViteNodeServer = new ViteNodeServer(viteServer, { const node = new ViteNodeServer(viteServer, {
deps: { deps: {
inline: [ inline: [
/\/node_modules\/(.*\/)?(nuxt|nuxt3|nuxt-nightly)\//, /\/node_modules\/(.*\/)?(nuxt|nuxt3|nuxt-nightly)\//,
@ -139,6 +139,7 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set<string> = ne
web: [], web: [],
}, },
}) })
const isExternal = createIsExternal(viteServer, ctx.nuxt.options.rootDir, ctx.nuxt.options.modulesDir) const isExternal = createIsExternal(viteServer, ctx.nuxt.options.rootDir, ctx.nuxt.options.modulesDir)
node.shouldExternalize = async (id: string) => { node.shouldExternalize = async (id: string) => {
const result = await isExternal(id) const result = await isExternal(id)
@ -156,13 +157,17 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set<string> = ne
if (isAbsolute(moduleId) && !isFileServingAllowed(moduleId, viteServer)) { if (isAbsolute(moduleId) && !isFileServingAllowed(moduleId, viteServer)) {
throw createError({ statusCode: 403 /* Restricted */ }) throw createError({ statusCode: 403 /* Restricted */ })
} }
const module = await node.fetchModule(moduleId).catch((err) => { const module = await node.fetchModule(moduleId).catch(async (err) => {
const errorData = { const errorData = {
code: 'VITE_ERROR', code: 'VITE_ERROR',
id: moduleId, id: moduleId,
stack: '', stack: '',
...err, ...err,
} }
if (!errorData.frame && errorData.code === 'PARSE_ERROR') {
errorData.frame = await node.transformModule(moduleId, 'web').then(({ code }) => `${err.message || ''}\n${code}`).catch(() => undefined)
}
throw createError({ data: errorData }) throw createError({ data: errorData })
}) })
return module return module

View File

@ -99,7 +99,11 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
}, },
plugins: [ plugins: [
// add resolver for files in public assets directories // add resolver for files in public assets directories
VitePublicDirsPlugin.vite({ sourcemap: !!nuxt.options.sourcemap.server }), VitePublicDirsPlugin.vite({
dev: nuxt.options.dev,
sourcemap: !!nuxt.options.sourcemap.server,
baseURL: nuxt.options.app.baseURL,
}),
composableKeysPlugin.vite({ composableKeysPlugin.vite({
sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client,
rootDir: nuxt.options.rootDir, rootDir: nuxt.options.rootDir,

View File

@ -30,7 +30,7 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
"cssnano": "^7.0.4", "cssnano": "^7.0.5",
"defu": "^6.1.4", "defu": "^6.1.4",
"esbuild-loader": "^4.2.2", "esbuild-loader": "^4.2.2",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
@ -50,7 +50,7 @@
"ohash": "^1.1.3", "ohash": "^1.1.3",
"pathe": "^1.1.2", "pathe": "^1.1.2",
"pify": "^6.1.0", "pify": "^6.1.0",
"postcss": "^8.4.40", "postcss": "^8.4.41",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-import-resolver": "^2.0.0", "postcss-import-resolver": "^2.0.0",
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
@ -60,7 +60,7 @@
"time-fix-plugin": "^2.0.7", "time-fix-plugin": "^2.0.7",
"ufo": "^1.5.4", "ufo": "^1.5.4",
"unenv": "^1.10.0", "unenv": "^1.10.0",
"unplugin": "^1.12.0", "unplugin": "^1.12.1",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"vue-bundle-renderer": "^2.1.0", "vue-bundle-renderer": "^2.1.0",
"vue-loader": "^17.4.2", "vue-loader": "^17.4.2",
@ -80,7 +80,7 @@
"@types/webpack-hot-middleware": "2.25.9", "@types/webpack-hot-middleware": "2.25.9",
"rollup": "4.20.0", "rollup": "4.20.0",
"unbuild": "3.0.0-rc.7", "unbuild": "3.0.0-rc.7",
"vue": "3.4.34" "vue": "3.4.37"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "^3.3.4" "vue": "^3.3.4"

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,6 @@
"3.x" "3.x"
], ],
"packageRules": [ "packageRules": [
{
"groupName": "vue",
"matchPackageNames": [
"vue"
],
"matchPackagePatterns": [
"^@vue/"
]
},
{ {
"groupName": "vitest", "groupName": "vitest",
"matchPackageNames": [ "matchPackageNames": [

View File

@ -392,12 +392,14 @@ describe('pages', () => {
expect(await page.locator('.client-only-script button').innerHTML()).toContain('2') expect(await page.locator('.client-only-script button').innerHTML()).toContain('2')
expect(await page.locator('.string-stateful-script').innerHTML()).toContain('1') expect(await page.locator('.string-stateful-script').innerHTML()).toContain('1')
expect(await page.locator('.string-stateful').innerHTML()).toContain('1') expect(await page.locator('.string-stateful').innerHTML()).toContain('1')
const waitForConsoleLog = page.waitForEvent('console', consoleLog => consoleLog.text() === 'has $el')
// ensure directives are reactive // ensure directives are reactive
await page.locator('button#show-all').click() await page.locator('button#show-all').click()
await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible())) await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible()))
.then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy())) .then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy()))
await waitForConsoleLog
expect(pageErrors).toEqual([]) expect(pageErrors).toEqual([])
await page.close() await page.close()
// don't expect any errors or warning on client-side navigation // don't expect any errors or warning on client-side navigation
@ -570,6 +572,14 @@ describe('pages', () => {
await normalInitialPage.close() await normalInitialPage.close()
}) })
it('groups routes', async () => {
for (const targetRoute of ['/group-page', '/nested-group/group-page', '/nested-group']) {
const { status } = await fetch(targetRoute)
expect(status).toBe(200)
}
})
}) })
describe('nuxt composables', () => { describe('nuxt composables', () => {
@ -1865,9 +1875,9 @@ describe('server components/islands', () => {
const text = (await page.innerText('pre')).replaceAll(/ data-island-uid="([^"]*)"/g, '').replace(/data-island-component="([^"]*)"/g, (_, content) => `data-island-component="${content.split('-')[0]}"`) const text = (await page.innerText('pre')).replaceAll(/ data-island-uid="([^"]*)"/g, '').replace(/data-island-component="([^"]*)"/g, (_, content) => `data-island-component="${content.split('-')[0]}"`)
if (isWebpack) { if (isWebpack) {
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component bellow is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"') expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"')
} else { } else {
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component bellow is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>"') expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>"')
} }
expect(text).toContain('async component that was very long') expect(text).toContain('async component that was very long')
@ -2126,7 +2136,7 @@ describe('component islands', () => {
"props": [], "props": [],
}, },
"fallback": { "fallback": {
"fallback": "<!--[--><div style="display:contents;"><div>fall slot -- index: 0</div><div class="fallback-slot-content"> wonderful fallback </div></div><div style="display:contents;"><div>back slot -- index: 1</div><div class="fallback-slot-content"> wonderful fallback </div></div><!--]--><!--teleport anchor-->", "fallback": "<!--teleport start anchor--><!--[--><div style="display:contents;"><div>fall slot -- index: 0</div><div class="fallback-slot-content"> wonderful fallback </div></div><div style="display:contents;"><div>back slot -- index: 1</div><div class="fallback-slot-content"> wonderful fallback </div></div><!--]--><!--teleport anchor-->",
"props": [ "props": [
{ {
"t": "fall", "t": "fall",
@ -2137,7 +2147,7 @@ describe('component islands', () => {
], ],
}, },
"hello": { "hello": {
"fallback": "<!--[--><div style="display:contents;"><div> fallback slot -- index: 0</div></div><div style="display:contents;"><div> fallback slot -- index: 1</div></div><div style="display:contents;"><div> fallback slot -- index: 2</div></div><!--]--><!--teleport anchor-->", "fallback": "<!--teleport start anchor--><!--[--><div style="display:contents;"><div> fallback slot -- index: 0</div></div><div style="display:contents;"><div> fallback slot -- index: 1</div></div><div style="display:contents;"><div> fallback slot -- index: 2</div></div><!--]--><!--teleport anchor-->",
"props": [ "props": [
{ {
"t": 0, "t": 0,
@ -2210,7 +2220,7 @@ describe('component islands', () => {
"link": [], "link": [],
"style": [], "style": [],
}, },
"html": "<div data-island-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component bellow is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-uid data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>", "html": "<div data-island-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-uid data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>",
"slots": {}, "slots": {},
} }
`) `)
@ -2221,7 +2231,7 @@ describe('component islands', () => {
"multiplier": 1, "multiplier": 1,
} }
`) `)
expect(teleportsEntries[0]![1].html).toMatchInlineSnapshot('"<div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--teleport anchor-->"') expect(teleportsEntries[0]![1].html).toMatchInlineSnapshot(`"<div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--teleport anchor-->"`)
}) })
} }
@ -2603,7 +2613,7 @@ describe('teleports', () => {
const html = await $fetch<string>('/nuxt-teleport') const html = await $fetch<string>('/nuxt-teleport')
// Teleport is appended to body, after the __nuxt div // Teleport is appended to body, after the __nuxt div
expect(html).toContain('<div><!--teleport start--><!--teleport end--><h1>Normal content</h1></div></div></div><span id="nuxt-teleport"><div>Nuxt Teleport</div><!--teleport anchor--></span><script') expect(html).toContain('<div><!--teleport start--><!--teleport end--><h1>Normal content</h1></div></div></div><span id="nuxt-teleport"><!--teleport start anchor--><div>Nuxt Teleport</div><!--teleport anchor--></span><script')
}) })
}) })

View File

@ -32,10 +32,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server') const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"205k"`) expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"211k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1346k"`) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1348k"`)
const packages = modules.files const packages = modules.files
.filter(m => m.endsWith('package.json')) .filter(m => m.endsWith('package.json'))
@ -58,6 +58,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
"db0", "db0",
"devalue", "devalue",
"entities", "entities",
"entities/dist/commonjs",
"estree-walker", "estree-walker",
"hookable", "hookable",
"source-map-js", "source-map-js",
@ -73,7 +74,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server') const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"528k"`) expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"535k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"80.3k"`) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"80.3k"`)

View File

@ -13,7 +13,7 @@
class="interactive-component-wrapper" class="interactive-component-wrapper"
style="border: solid 1px red;" style="border: solid 1px red;"
> >
The component bellow is not a slot but declared as interactive The component below is not a slot but declared as interactive
<Counter <Counter
nuxt-client nuxt-client

View File

@ -0,0 +1,16 @@
<template>
<div>
<ClientSetupScript
ref="clientSetupScript"
class="client-only-script-setup"
foo="hello"
/>
</div>
</template>
<script setup lang="ts">
const clientSetupScript = ref<{ $el: HTMLElement }>()
onMounted(() => {
console.log(clientSetupScript.value?.$el as HTMLElement ? 'has $el' : 'no $el')
})
</script>

View File

@ -0,0 +1,5 @@
<template>
<div>
Hello from new group
</div>
</template>

View File

@ -59,6 +59,7 @@
class="no-state-hidden" class="no-state-hidden"
/> />
<WrapClientComponent v-if="show" />
<button <button
class="test-ref-1" class="test-ref-1"
@click="stringStatefulComp.add" @click="stringStatefulComp.add"
@ -94,16 +95,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Ref } from 'vue'
// bypass client import protection to ensure this is treeshaken from .client components // bypass client import protection to ensure this is treeshaken from .client components
import BreaksServer from '~~/components/BreaksServer.client' import BreaksServer from '~~/components/BreaksServer.client'
type Comp = Ref<{ add: () => void }> type Comp = { add: () => void }
const stringStatefulComp = ref<Comp>(null)
const stringStatefulComp = ref(null) as any as Comp const stringStatefulScriptComp = ref<Comp>(null)
const stringStatefulScriptComp = ref(null) as any as Comp const clientScript = ref<Comp>(null)
const clientScript = ref(null) as any as Comp const clientSetupScript = ref<Comp>(null)
const clientSetupScript = ref(null) as any as Comp
const BreakServerComponent = defineAsyncComponent(() => { const BreakServerComponent = defineAsyncComponent(() => {
return import('./../components/BreaksServer.client') return import('./../components/BreaksServer.client')
}) })

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// explicit import to bypass client import protection // explicit import to bypass client import protection
import BreaksServer from '../components/BreaksServer.client' import BreaksServer from '../components/BreaksServer.client'
// ensure treeshake-client-only module remove theses imports without breaking // ensure treeshake-client-only module remove these imports without breaking
import TestGlobal from '../components/global/TestGlobal.vue' import TestGlobal from '../components/global/TestGlobal.vue'
// direct import of .client components should be treeshaken // direct import of .client components should be treeshaken
import { FunctionalComponent, LazyClientOnlyScript } from '#components' import { FunctionalComponent, LazyClientOnlyScript } from '#components'

View File

@ -0,0 +1,5 @@
<template>
<div>
Page deep in group
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Index page of a group
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Page deep, deep in group
</div>
</template>

View File

@ -2,6 +2,7 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3' import { defineEventHandler } from 'h3'
import { destr } from 'destr'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
@ -691,6 +692,38 @@ describe('useCookie', () => {
expect(computedVal.value).toBe(0) expect(computedVal.value).toBe(0)
}) })
it('cookie decode function should be invoked once', () => {
// Pre-set cookies
document.cookie = 'foo=Foo'
document.cookie = 'bar=%7B%22s2%22%3A0%7D'
document.cookie = 'baz=%7B%22s2%22%3A0%7D'
let barCallCount = 0
const bazCookie = useCookie<{ s2: number }>('baz', {
default: () => ({ s2: -1 }),
decode (value) {
barCallCount++
return destr(decodeURIComponent(value))
},
})
bazCookie.value.s2++
expect(bazCookie.value.s2).toEqual(1)
expect(barCallCount).toBe(1)
let quxCallCount = 0
const quxCookie = useCookie<{ s3: number }>('qux', {
default: () => ({ s3: -1 }),
filter: key => key === 'bar' || key === 'baz',
decode (value) {
quxCallCount++
return destr(decodeURIComponent(value))
},
})
quxCookie.value.s3++
expect(quxCookie.value.s3).toBe(0)
expect(quxCallCount).toBe(2)
})
it('should not watch custom cookie refs when shallow', () => { it('should not watch custom cookie refs when shallow', () => {
for (const value of ['shallow', false] as const) { for (const value of ['shallow', false] as const) {
const user = useCookie('shallowUserInfo', { const user = useCookie('shallowUserInfo', {