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
- name: Cache dist
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
retention-days: 3
name: dist
@ -85,7 +85,7 @@ jobs:
run: pnpm install
- name: Initialize CodeQL
uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
uses: github/codeql-action/init@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
with:
languages: javascript
queries: +security-and-quality
@ -97,7 +97,7 @@ jobs:
path: packages
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
uses: github/codeql-action/analyze@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
with:
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
# format to the repository Actions tab.
- 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()
with:
name: SARIF file
@ -68,7 +68,7 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
uses: github/codeql-action/upload-sarif@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
if: github.repository == 'nuxt/nuxt' && success()
with:
sarif_file: results.sarif

View File

@ -161,7 +161,13 @@ export default defineVitestConfig({
#### `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
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.
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).
The passed in component will be rendered inside a `<div id="test-wrapper"></div>`.
@ -266,7 +273,9 @@ mockNuxtImport('useStorage', () => {
// 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.

View File

@ -12,19 +12,19 @@ To enable type-checking at build or development time, install `vue-tsc` and `typ
::code-group
```bash [yarn]
yarn add --dev vue-tsc@^1 typescript
yarn add --dev vue-tsc typescript
```
```bash [npm]
npm install --save-dev vue-tsc@^1 typescript
npm install --save-dev vue-tsc typescript
```
```bash [pnpm]
pnpm add -D vue-tsc@^1 typescript
pnpm add -D vue-tsc typescript
```
```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
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
@ -226,6 +226,22 @@ definePageMeta({
: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
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.
:read-more{to="/docs/guide/going-further/custom-routing"}
:read-more{to="/docs/guide/recipes/custom-routing"}
## 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
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).
```ts [app/router.options.ts]
import type { RouterConfig } from '@nuxt/schema'
export default <RouterConfig> {
export default {
// https://router.vuejs.org/api/interfaces/routeroptions.html#routes
routes: (_routes) => [
{
@ -25,7 +25,7 @@ export default <RouterConfig> {
component: () => import('~/pages/home.vue').then(r => r.default || r)
}
],
}
} satisfies RouterConfig
```
::note
@ -90,8 +90,8 @@ This is the recommended way to specify [router options](/docs/api/nuxt-config#ro
```ts [app/router.options.ts]
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.
@ -174,8 +174,8 @@ You can optionally override history mode using a function that accepts the base
import type { RouterConfig } from '@nuxt/schema'
import { createMemoryHistory } from 'vue-router'
export default <RouterConfig> {
export default {
// https://router.vuejs.org/api/interfaces/routeroptions.html
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
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
@ -14,7 +14,7 @@ The `to` target of [`<Teleport>`](https://vuejs.org/guide/built-ins/teleport.htm
<button @click="open = true">
Open Modal
</button>
<Teleport to="body">
<Teleport to="#teleports">
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<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.
::
### 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
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`
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]`**

View File

@ -209,6 +209,7 @@ type NuxtMiddleware = {
interface AddRouteMiddlewareOptions {
override?: boolean
prepend?: boolean
}
```
@ -246,7 +247,21 @@ A middleware object or an array of middleware objects with the following propert
**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
@ -272,7 +287,7 @@ export default defineNuxtModule({
name: 'auth',
path: resolver.resolve('runtime/auth.ts'),
global: true
})
}, { prepend: true })
}
})
```

View File

@ -103,7 +103,7 @@ This feature is not yet supported in Nuxt 3.
## `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.
```diff [pages/index.vue]

View File

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

View File

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

View File

@ -35,6 +35,11 @@ export interface AddRouteMiddlewareOptions {
* @default false
*/
override?: boolean
/**
* Prepend middleware to the list
* @default false
*/
prepend?: boolean
}
export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], options: AddRouteMiddlewareOptions = {}) {
@ -51,6 +56,8 @@ export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], op
} else {
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 {
app.middleware.push({ ...middleware })
}

View File

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

View File

@ -1,5 +1,6 @@
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue'
import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue'
import { isPromise } from '@vue/shared'
import { useNuxtApp } from '../nuxt'
import { getFragmentHTML } from './utils'
@ -42,9 +43,10 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
const clone = { ...component }
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) => {
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)
return (res.children === null || typeof res.children === 'string')
? cloneVNode(res)
@ -63,33 +65,39 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
}
clone.setup = (props, ctx) => {
const nuxtApp = useNuxtApp()
const mounted$ = ref(import.meta.client && nuxtApp.isHydrating === false)
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
const directives = extractDirectives(instance)
// prevent attrs inheritance since a staticVNode is rendered before hydration
for (const key in attrs) {
delete instance.attrs[key]
onMounted(() => {
Object.assign(instance.attrs, attrs)
instance.vnode.dirs = directives
})
}
const mounted$ = ref(false)
onMounted(() => {
Object.assign(instance.attrs, attrs)
instance.vnode.dirs = directives
mounted$.value = true
})
const setupState = component.setup?.(props, ctx) || {}
return Promise.resolve(component.setup?.(props, ctx) || {})
.then((setupState) => {
if (isPromise(setupState)) {
return Promise.resolve(setupState).then((setupState) => {
if (typeof setupState !== 'function') {
setupState = setupState || {}
setupState.mounted$ = mounted$
return setupState
}
return (...args: any[]) => {
if (mounted$.value) {
if (import.meta.client && (mounted$.value || !nuxtApp.isHydrating)) {
const res = setupState(...args)
return (res.children === null || typeof res.children === 'string')
? 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)

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>): CookieRef<T> {
const opts = { ...CookieDefaults, ..._opts }
opts.filter ??= key => key === name
const cookies = readRawCookies(opts) || {}
let delay: number | undefined

View File

@ -152,7 +152,7 @@ export function useFetch<
let controller: AbortController
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
/**
@ -164,7 +164,7 @@ export function useFetch<
const timeoutLength = toValue(opts.timeout)
let timeoutId: NodeJS.Timeout
if (timeoutLength) {
timeoutId = setTimeout(() => controller.abort(), timeoutLength)
timeoutId = setTimeout(() => controller.abort('Request aborted due to timeout.'), timeoutLength)
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.
*
* The generated ID is unique in the context of the current Nuxt instance and key.
*/
export function useId (): 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.')
}
nuxtApp._id ||= 0
nuxtApp._genId ||= 0
instance._nuxtIdIndex ||= {}
instance._nuxtIdIndex[key] ||= 0
@ -32,7 +34,7 @@ export function useId (key?: string): string {
if (import.meta.server) {
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)
return ids[instanceIndex]
}
@ -54,5 +56,5 @@ export function useId (key?: string): string {
}
// 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 { parse } from 'devalue'
import { useHead } from '@unhead/vue'
import { getCurrentInstance, onServerPrefetch } from 'vue'
import { getCurrentInstance, onServerPrefetch, reactive } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import type { NuxtPayload } from '../nuxt'
@ -122,6 +122,10 @@ export async function getNuxtClientPayload () {
...window.__NUXT__,
}
if (payloadCache!.config?.public) {
payloadCache!.config.public = reactive(payloadCache!.config.public)
}
return payloadCache
}

View File

@ -10,9 +10,31 @@ interface Preview {
_initialized?: boolean
}
/**
* Options for configuring preview mode.
*/
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
/**
* 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
/**
* 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
@ -54,9 +76,10 @@ export function usePreviewMode<S extends EnteredState> (options: PreviewModeOpti
}
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) {
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
export function useElementScriptTrigger (...args: unknown[]) {
renderStubMessage('useElementScriptTrigger')
export function useScriptTriggerElement (...args: unknown[]) {
renderStubMessage('useScriptTriggerElement')
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useConsentScriptTrigger (...args: unknown[]) {
renderStubMessage('useConsentScriptTrigger')
export function useScriptTriggerConsent (...args: unknown[]) {
renderStubMessage('useScriptTriggerConsent')
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useAnalyticsPageEvent (...args: unknown[]) {
renderStubMessage('useAnalyticsPageEvent')
export function useScriptEventPage (...args: unknown[]) {
renderStubMessage('useScriptEventPage')
}
// 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'
function getNuxtAppCtx (appName = appId || 'nuxt-app') {
return getContext<NuxtApp>(appName, {
function getNuxtAppCtx (id = appId || 'nuxt-app') {
return getContext<NuxtApp>(id, {
asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server,
})
}
@ -98,8 +98,6 @@ export interface NuxtPayload {
}
interface _NuxtApp {
/** @internal */
_name: string
vueApp: App<Element>
versions: Record<string, string>
@ -113,8 +111,15 @@ interface _NuxtApp {
/** @internal */
_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 */
_scope: EffectScope
/** @internal */
@ -244,13 +249,17 @@ export type ObjectPluginInput<Injections extends Record<string, unknown> = Recor
export interface CreateOptions {
vueApp: NuxtApp['vueApp']
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 */
export function createNuxtApp (options: CreateOptions) {
let hydratingCount = 0
const nuxtApp: NuxtApp = {
_name: appId || 'nuxt-app',
_id: options.id || appId || 'nuxt-app',
_scope: effectScope(),
provide: undefined,
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>) {
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) {
return nuxt.vueApp.runWithContext(() => nuxtAppCtx.callAsync(nuxt as NuxtApp, fn))
} else {
@ -507,13 +516,13 @@ export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp |
* @since 3.10.0
*/
export function tryUseNuxtApp (): NuxtApp | null
export function tryUseNuxtApp (appName?: string): NuxtApp | null {
export function tryUseNuxtApp (id?: string): NuxtApp | null {
let nuxtAppInstance
if (hasInjectionContext()) {
nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt
}
nuxtAppInstance = nuxtAppInstance || getNuxtAppCtx(appName).tryUse()
nuxtAppInstance = nuxtAppInstance || getNuxtAppCtx(id).tryUse()
return nuxtAppInstance || null
}
@ -526,9 +535,9 @@ export function tryUseNuxtApp (appName?: string): NuxtApp | null {
* @since 3.0.0
*/
export function useNuxtApp (): NuxtApp
export function useNuxtApp (appName?: string): NuxtApp {
// @ts-expect-error internal usage of appName
const nuxtAppInstance = tryUseNuxtApp(appName)
export function useNuxtApp (id?: string): NuxtApp {
// @ts-expect-error internal usage of id
const nuxtAppInstance = tryUseNuxtApp(id)
if (!nuxtAppInstance) {
if (import.meta.dev) {

View File

@ -51,3 +51,23 @@ declare module 'vue' {
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'] = {}
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] = {
...ssrContext.islandContext.components[clientUid],
html,

View File

@ -1,6 +1,6 @@
import { existsSync } from 'node:fs'
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 { generateTypes, resolveSchema } from 'untyped'
import escapeRE from 'escape-string-regexp'
@ -98,19 +98,36 @@ export const serverPluginTemplate: NuxtTemplate = {
export const pluginsDeclaration: NuxtTemplate = {
filename: 'types/plugins.d.ts',
getContents: async (ctx) => {
const EXTENSION_RE = new RegExp(`(?<=\\w)(${ctx.nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g')
getContents: async ({ nuxt, app }) => {
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[] = []
for (const p of ctx.app.plugins) {
const sources = [p.src, p.src.replace(EXTENSION_RE, '.d.ts')]
if (!isAbsolute(p.src)) {
tsImports.push(p.src.replace(EXTENSION_RE, ''))
} 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 pluginNames: string[] = []
function exists (path: string) {
return app.templates.some(t => t.write && path === t.dst) || existsSync(path)
}
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'
import type { Plugin } from '#app'
@ -126,10 +143,18 @@ declare module '#app' {
interface NuxtApp extends NuxtAppInjections { }
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' {
interface ComponentCustomProperties extends NuxtAppInjections { }
}
@ -143,36 +168,74 @@ const adHocModules = ['router', 'pages', 'imports', 'meta', 'components', 'nuxt-
export const schemaTemplate: NuxtTemplate = {
filename: 'types/schema.d.ts',
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 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)
for (const key in nuxt.options.runtimeConfig) {
if (key !== 'public') {
privateRuntimeConfig[key] = nuxt.options.runtimeConfig[key]
}
}
const moduleOptionsInterface = [
...modules.map(([configKey, importName]) =>
` [${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, meta]) => `[${genString(meta?.rawPath || importName)}, Exclude<NuxtConfig[${configKey}], boolean>]`).join(' | ')})[],` : '',
]
const moduleOptionsInterface = (jsdocTags: boolean) => [
...modules.flatMap(([configKey, importName, mod]) => {
let link: string | undefined
// 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 [
'import { NuxtModule, RuntimeConfig } from \'@nuxt/schema\'',
'declare module \'@nuxt/schema\' {',
' 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\' {',
' interface NuxtConfig {',
moduleOptionsInterface,
...moduleOptionsInterface(true),
' }',
generateTypes(await resolveSchema(privateRuntimeConfig as Record<string, JSValue>),
{

View File

@ -1,5 +1,5 @@
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) {
if (arr.length < 2) {

View File

@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url'
import { extname } from 'pathe'
import { parseQuery, parseURL } from 'ufo'
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))
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 = {
imports: [
'useConsentScriptTrigger',
'useAnalyticsPageEvent',
'useElementScriptTrigger',
'useScriptTriggerConsent',
'useScriptEventPage',
'useScriptTriggerElement',
'useScript',
'useScriptGoogleAnalytics',
'useScriptPlausibleAnalytics',

View File

@ -58,7 +58,7 @@ function _getHashElementScrollMarginTop (selector: string): number {
try {
const elem = document.querySelector(selector)
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 {
// 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 { NuxtPage } from 'nuxt/schema'
import { uniqueBy } from '../core/utils'
import { getLoader, uniqueBy } from '../core/utils'
import { toArray } from '../utils'
import { distDir } from '../dirs'
@ -23,6 +23,7 @@ enum SegmentParserState {
dynamic,
optional,
catchall,
group,
}
enum SegmentTokenType {
@ -30,6 +31,7 @@ enum SegmentTokenType {
dynamic,
optional,
catchall,
group,
}
interface SegmentToken {
@ -115,7 +117,13 @@ export function generateRoutesFromFiles (files: ScannedFile[], options: Generate
const segment = segments[i]
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
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] }
const script = extractScriptContent(contents)
const loader = getLoader(absolutePath)
const script = !loader ? null : loader === 'vue' ? extractScriptContent(contents) : { code: contents, loader }
if (!script) {
metaCache[absolutePath] = {}
return {}
@ -297,7 +306,9 @@ function getRoutePath (tokens: SegmentToken[]): string {
? `:${token.value}()`
: token.type === SegmentTokenType.catchall
? `:${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
: state === SegmentParserState.optional
? SegmentTokenType.optional
: SegmentTokenType.catchall,
: state === SegmentParserState.catchall
? SegmentTokenType.catchall
: SegmentTokenType.group,
value: buffer,
})
@ -342,6 +355,8 @@ function parseSegment (segment: string) {
buffer = ''
if (c === '[') {
state = SegmentParserState.dynamic
} else if (c === '(') {
state = SegmentParserState.group
} else {
i--
state = SegmentParserState.static
@ -352,6 +367,9 @@ function parseSegment (segment: string) {
if (c === '[') {
consumeBuffer()
state = SegmentParserState.dynamic
} else if (c === '(') {
consumeBuffer()
state = SegmentParserState.group
} else {
buffer += c
}
@ -360,6 +378,7 @@ function parseSegment (segment: string) {
case SegmentParserState.catchall:
case SegmentParserState.dynamic:
case SegmentParserState.optional:
case SegmentParserState.group:
if (buffer === '...') {
buffer = ''
state = SegmentParserState.catchall
@ -374,10 +393,16 @@ function parseSegment (segment: string) {
consumeBuffer()
}
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)) {
buffer += c
} else {
// console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`)
}
break

View File

@ -339,6 +339,34 @@
"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": [
{
"alias": "mockMeta?.alias || []",

View File

@ -234,6 +234,25 @@
"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": [
{
"children": [

View File

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

View File

@ -10,6 +10,16 @@ describe('page metadata', () => {
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 () => {
const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>`
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> = {}

View File

@ -42,23 +42,23 @@
"@unhead/schema": "1.9.16",
"@vitejs/plugin-vue": "5.1.2",
"@vitejs/plugin-vue-jsx": "4.0.0",
"@vue/compiler-core": "3.4.34",
"@vue/compiler-sfc": "3.4.34",
"@vue/compiler-core": "3.4.37",
"@vue/compiler-sfc": "3.4.37",
"@vue/language-core": "2.0.29",
"c12": "2.0.0-beta.1",
"esbuild-loader": "4.2.2",
"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",
"ofetch": "1.3.4",
"unbuild": "3.0.0-rc.7",
"unctx": "2.3.1",
"unenv": "1.10.0",
"vite": "5.3.5",
"vue": "3.4.34",
"vite": "5.4.0",
"vue": "3.4.37",
"vue-bundle-renderer": "2.1.0",
"vue-loader": "17.4.2",
"vue-router": "4.4.2",
"vue-router": "4.4.3",
"webpack": "5.93.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: {
$resolve: (val: string) => val ?? 'nuxt-app',

View File

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

View File

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

View File

@ -8,12 +8,29 @@ import { normalizeViteManifest } from 'vue-bundle-renderer'
import type { ViteBuildContext } from './vite'
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
const clientDist = resolve(ctx.nuxt.options.buildDir, 'dist/client')
const serverDist = resolve(ctx.nuxt.options.buildDir, 'dist/server')
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 BASE_RE = new RegExp(`^${escapeRE(buildAssetsDir)}`)

View File

@ -1,77 +1,112 @@
import { existsSync } from 'node:fs'
import { useNitro } from '@nuxt/kit'
import { createUnplugin } from 'unplugin'
import type { UnpluginOptions } from 'unplugin'
import { withLeadingSlash, withTrailingSlash } from 'ufo'
import { dirname, relative } from 'pathe'
import MagicString from 'magic-string'
import { isCSSRequest } from 'vite'
const PREFIX = 'virtual:public?'
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()
return {
name: 'nuxt:vite-public-dir-resolution',
const devTransformPlugin: UnpluginOptions = {
name: 'nuxt:vite-public-dir-resolution-dev',
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 }
transform (code, id) {
if (!isCSSRequest(id) || !CSS_URL_SINGLE_RE.test(code)) { 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})`)
s.replace(full, `url(${options.baseURL}${url})`)
}
}
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
}
}
},
},
}
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 () {

View File

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

View File

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

View File

@ -99,7 +99,11 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
},
plugins: [
// 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({
sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client,
rootDir: nuxt.options.rootDir,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -392,12 +392,14 @@ describe('pages', () => {
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').innerHTML()).toContain('1')
const waitForConsoleLog = page.waitForEvent('console', consoleLog => consoleLog.text() === 'has $el')
// ensure directives are reactive
await page.locator('button#show-all').click()
await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible()))
.then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy()))
await waitForConsoleLog
expect(pageErrors).toEqual([])
await page.close()
// don't expect any errors or warning on client-side navigation
@ -570,6 +572,14 @@ describe('pages', () => {
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', () => {
@ -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]}"`)
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 {
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')
@ -2126,7 +2136,7 @@ describe('component islands', () => {
"props": [],
},
"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": [
{
"t": "fall",
@ -2137,7 +2147,7 @@ describe('component islands', () => {
],
},
"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": [
{
"t": 0,
@ -2210,7 +2220,7 @@ describe('component islands', () => {
"link": [],
"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": {},
}
`)
@ -2221,7 +2231,7 @@ describe('component islands', () => {
"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')
// 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 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)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1346k"`)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1348k"`)
const packages = modules.files
.filter(m => m.endsWith('package.json'))
@ -58,6 +58,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
"db0",
"devalue",
"entities",
"entities/dist/commonjs",
"estree-walker",
"hookable",
"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 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)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"80.3k"`)

View File

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

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
// explicit import to bypass client import protection
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'
// direct import of .client components should be treeshaken
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 { defineEventHandler } from 'h3'
import { destr } from 'destr'
import { mount } from '@vue/test-utils'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
@ -691,6 +692,38 @@ describe('useCookie', () => {
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', () => {
for (const value of ['shallow', false] as const) {
const user = useCookie('shallowUserInfo', {