Merge branch 'main' of github.com:nuxt/nuxt into feat/unhead-v2

This commit is contained in:
harlan 2025-01-21 15:10:17 +11:00
commit 96f6e16414
95 changed files with 1007 additions and 365 deletions

View File

@ -301,8 +301,6 @@ jobs:
NPM_CONFIG_PROVENANCE: true
release-pr:
concurrency:
group: release
if: github.repository_owner == 'nuxt' && github.event_name != 'push'
needs:
- build

View File

@ -26,6 +26,6 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# From https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
- name: Check workflow files
uses: docker://rhysd/actionlint:1.7.6@sha256:e3856d413f923accc4120884ff79f6bdba3dd53fd42884d325f21af61cc15ce0
uses: docker://rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
with:
args: -color

23
debug/build-config.ts Normal file
View File

@ -0,0 +1,23 @@
import { fileURLToPath } from 'node:url'
import process from 'node:process'
import type { InputPluginOption } from 'rollup'
import type { BuildOptions } from 'unbuild'
import { AnnotateFunctionTimingsPlugin } from './plugins/timings-unbuild'
export const stubOptions = {
jiti: {
transformOptions: {
babel: {
plugins: (process.env.TIMINGS_DEBUG ? [fileURLToPath(new URL('./plugins/timings-babel.mjs', import.meta.url))] : []) as any,
},
},
},
} satisfies BuildOptions['stubOptions']
export function addRollupTimingsPlugin (options: { plugins: InputPluginOption[] }) {
if (process.env.TIMINGS_DEBUG) {
options.plugins.push(AnnotateFunctionTimingsPlugin())
}
}

View File

@ -0,0 +1,152 @@
// @ts-check
import { declare } from '@babel/helper-plugin-utils'
import { types as t } from '@babel/core'
// inlined from https://github.com/danielroe/errx
function captureStackTrace () {
const IS_ABSOLUTE_RE = /^[/\\](?![/\\])|^[/\\]{2}(?!\.)|^[a-z]:[/\\]/i
const LINE_RE = /^\s+at (?:(?<function>[^)]+) \()?(?<source>[^)]+)\)?$/u
const SOURCE_RE = /^(?<source>.+):(?<line>\d+):(?<column>\d+)$/u
if (!Error.captureStackTrace) {
return []
}
// eslint-disable-next-line unicorn/error-message
const stack = new Error()
Error.captureStackTrace(stack)
const trace = []
for (const line of stack.stack?.split('\n') || []) {
const parsed = LINE_RE.exec(line)?.groups
if (!parsed) {
continue
}
if (!parsed.source) {
continue
}
const parsedSource = SOURCE_RE.exec(parsed.source)?.groups
if (parsedSource) {
Object.assign(parsed, parsedSource)
}
if (IS_ABSOLUTE_RE.test(parsed.source)) {
parsed.source = `file://${parsed.source}`
}
if (parsed.source === import.meta.url) {
continue
}
for (const key of ['line', 'column']) {
if (parsed[key]) {
// @ts-expect-error
parsed[key] = Number(parsed[key])
}
}
trace.push(parsed)
}
return trace
}
export const leading = `
const ___captureStackTrace = ${captureStackTrace.toString()};
globalThis.___calls ||= {};
globalThis.___timings ||= {};
globalThis.___callers ||= {};`
function onExit () {
if (globalThis.___logged) { return }
globalThis.___logged = true
// worst by total time
const timings = Object.entries(globalThis.___timings)
const topFunctionsTotalTime = timings
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, time]) => ({
name,
time: Number(Number(time).toFixed(2)),
calls: globalThis.___calls[name],
callers: globalThis.___callers[name] && Object.entries(globalThis.___callers[name]).map(([name, count]) => `${name.trim()} (${count})`).join(', '),
}))
// eslint-disable-next-line no-console
console.log('Top 10 functions by total time:')
// eslint-disable-next-line no-console
console.table(topFunctionsTotalTime)
// worst by average time (excluding single calls)
const topFunctionsAverageTime = timings
.filter(([name]) => (globalThis.___calls[name] || 0) > 1)
.map(([name, time]) => [name, time / (globalThis.___calls[name] || 1)])
// @ts-expect-error
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, time]) => ({
name,
time: Number(Number(time).toFixed(2)),
calls: name && globalThis.___calls[name],
callers: name && globalThis.___callers[name] && Object.entries(globalThis.___callers[name]).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name.trim()} (${count})`).join(', '),
}))
// eslint-disable-next-line no-console
console.log('Top 10 functions by average time:')
// eslint-disable-next-line no-console
console.table(topFunctionsAverageTime)
}
export const trailing = `process.on("exit", ${onExit.toString()})`
/** @param {string} functionName */
export function generateInitCode (functionName) {
return `
___calls.${functionName} = (___calls.${functionName} || 0) + 1;
___timings.${functionName} ||= 0;
const ___now = Date.now();`
}
/** @param {string} functionName */
export function generateFinallyCode (functionName) {
return `
___timings.${functionName} += Date.now() - ___now;
try {
const ___callee = ___captureStackTrace()[1]?.function;
if (___callee) {
___callers.${functionName} ||= {};
___callers.${functionName}[' ' + ___callee] = (___callers.${functionName}[' ' + ___callee] || 0) + 1;
}
} catch {}`
}
export default declare((api) => {
api.assertVersion(7)
return {
name: 'annotate-function-timings',
visitor: {
Program (path) {
path.unshiftContainer('body', t.expressionStatement(t.identifier(leading)))
path.pushContainer('body', t.expressionStatement(t.identifier(trailing)))
},
FunctionDeclaration (path) {
const functionName = path.node.id?.name
const start = path.get('body').get('body')[0]
const end = path.get('body').get('body').pop()
if (!functionName || ['createJiti', '___captureStackTrace', '_interopRequireDefault'].includes(functionName) || !start || !end) { return }
const initCode = generateInitCode(functionName)
const finallyCode = generateFinallyCode(functionName)
const originalCode = path.get('body').get('body').map(statement => statement.node)
path.get('body').get('body').forEach(statement => statement.remove())
path.get('body').unshiftContainer('body', t.expressionStatement(t.identifier(initCode)))
path.get('body').pushContainer('body', t.tryStatement(
t.blockStatement(originalCode),
t.catchClause(t.identifier('e'), t.blockStatement([])),
t.blockStatement([t.expressionStatement(t.identifier(finallyCode))]),
))
},
},
}
})

View File

@ -0,0 +1,55 @@
import type { Plugin } from 'rollup'
import { parse } from 'acorn'
import { type Node, walk } from 'estree-walker'
import MagicString from 'magic-string'
import tsBlankSpace from 'ts-blank-space'
import { generateFinallyCode, generateInitCode, leading, trailing } from './timings-babel.mjs'
declare global {
// eslint-disable-next-line no-var
var ___logged: boolean
// eslint-disable-next-line no-var
var ___timings: Record<string, number>
// eslint-disable-next-line no-var
var ___calls: Record<string, number>
// eslint-disable-next-line no-var
var ___callers: Record<string, number>
}
export function AnnotateFunctionTimingsPlugin () {
return {
name: 'timings',
transform: {
order: 'post',
handler (code, id) {
const s = new MagicString(code)
try {
const ast = parse(tsBlankSpace(code), { sourceType: 'module', ecmaVersion: 'latest', locations: true })
walk(ast as Node, {
enter (node) {
if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
const functionName = node.id.name
const start = (node.body as Node & { start: number, end: number }).start
const end = (node.body as Node & { start: number, end: number }).end
if (!functionName || ['createJiti', 'captureStackTrace', '___captureStackTrace', '_interopRequireDefault'].includes(functionName) || !start || !end) { return }
s.prependLeft(start + 1, generateInitCode(functionName) + 'try {')
s.appendRight(end - 1, `} finally { ${generateFinallyCode(functionName)} }`)
}
},
})
code = s.toString()
if (!code.includes(leading)) {
code = [leading, code, trailing].join('\n')
}
return code
} catch (e) {
// eslint-disable-next-line no-console
console.log(e, code, id)
}
},
},
} satisfies Plugin
}

View File

@ -174,6 +174,7 @@ Under the hood, `mountSuspended` wraps `mount` from `@vue/test-utils`, so you ca
For example:
```ts twoslash
// @noErrors
import { it, expect } from 'vitest'
import type { Component } from 'vue'
declare module '#components' {
@ -194,6 +195,7 @@ it('can mount some component', async () => {
```
```ts twoslash
// @noErrors
import { it, expect } from 'vitest'
// ---cut---
// tests/components/SomeComponents.nuxt.spec.ts
@ -225,6 +227,7 @@ The passed in component will be rendered inside a `<div id="test-wrapper"></div>
Examples:
```ts twoslash
// @noErrors
import { it, expect } from 'vitest'
import type { Component } from 'vue'
declare module '#components' {
@ -243,6 +246,7 @@ it('can render some component', async () => {
```
```ts twoslash
// @noErrors
import { it, expect } from 'vitest'
// ---cut---
// tests/App.nuxt.spec.ts

View File

@ -202,6 +202,19 @@ const { data: discounts, status } = await useAsyncData('cart-discount', async ()
</script>
```
::note
`useAsyncData` is for fetching and caching data, not triggering side effects like calling Pinia actions, as this can cause unintended behavior such as repeated executions with nullish values. If you need to trigger side effects, use the [`callOnce`](/docs/api/utils/call-once) utility to do so.
```vue
<script setup lang="ts">
const offersStore = useOffersStore()
// you can't do this
await useAsyncData(() => offersStore.getOffer(route.params.slug))
</script>
```
::
::read-more{to="/docs/api/composables/use-async-data"}
Read more about `useAsyncData`.
::

View File

@ -36,14 +36,24 @@ The module automatically loads and parses them.
## Render Content
To render content pages, add a [catch-all route](/docs/guide/directory-structure/pages/#catch-all-route) using the [`<ContentDoc>`](https://content.nuxt.com/components/content-doc) component:
To render content pages, add a [catch-all route](/docs/guide/directory-structure/pages/#catch-all-route) using the [`<ContentRenderer>`](https://content.nuxt.com/docs/components/content-renderer) component:
```vue [pages/[...slug\\].vue]
<script lang="ts" setup>
const route = useRoute()
const { data: page } = await useAsyncData(route.path, () => {
return queryCollection('content').path(route.path).first()
})
</script>
<template>
<main>
<!-- ContentDoc returns content for `$route.path` by default or you can pass a `path` prop -->
<ContentDoc />
</main>
<div>
<header><!-- ... --></header>
<ContentRenderer v-if="page" :value="page" />
<footer><!-- ... --></footer>
</div>
</template>
```

View File

@ -28,7 +28,7 @@ Note that removing a variable from `.env` or removing the `.env` file entirely w
If you want to use a different file - for example, to use `.env.local` or `.env.production` - you can do so by passing the `--dotenv` flag when using `nuxi`.
```bash [Terminal]
npx nuxi dev --dotenv .env.local
npx nuxi dev -- --dotenv .env.local
```
When updating `.env` in development mode, the Nuxt instance is automatically restarted to apply new values to the `process.env`.

View File

@ -116,6 +116,8 @@ export function useAPI<T>(
This example demonstrates how to use a custom `useFetch`, but the same structure is identical for a custom `useAsyncData`.
::
:link-example{to="/docs/examples/advanced/use-custom-fetch-composable"}
::callout{icon="i-simple-icons-youtube" color="red" to="https://www.youtube.com/watch?v=jXH8Tr-exhI"}
Watch a video about custom `$fetch` and Repository Pattern in Nuxt.
::

View File

@ -16,19 +16,24 @@ links:
In this example, we use `<NuxtLink>` component to link to another page of the application.
::code-group
```vue [pages/index.vue]
<template>
<NuxtLink to="/about">
About page
</NuxtLink>
<!-- <a href="/about">...</a> (+Vue Router & prefetching) -->
<NuxtLink to="/about">About page</NuxtLink>
</template>
```
```html [(Renders as) index.html]
<!-- (Vue Router & Smart Prefetching) -->
<a href="/about">About page</a>
```
::
### Passing Params to Dynamic Routes
In this example, we pass the `id` param to link to the route `~/pages/posts/[id].vue`.
::code-group
```vue [pages/index.vue]
<template>
<NuxtLink :to="{ name: 'posts-id', params: { id: 123 } }">
@ -37,26 +42,46 @@ In this example, we pass the `id` param to link to the route `~/pages/posts/[id]
</template>
```
```html [(Renders as) index.html]
<a href="/posts/123">Post 123</a>
```
::
::tip
Check out the Pages panel in Nuxt DevTools to see the route name and the params it might take.
::
### Handling 404s
### Handling Static File and Cross-App Links
When using `<NuxtLink>` for `/public` directory files or when pointing to a different app on the same domain, you should use the `external` prop.
By default, `<NuxtLink>` uses Vue Router's client side navigation for relative route. When linking to static files in the `/public` directory or to another application hosted on the same domain, it might result in unexpected 404 errors because they are not part of the client routes. In such cases, you can use the `external` prop with `<NuxtLink>` to bypass Vue Router's internal routing mechanism.
Using `external` forces the link to be rendered as an `a` tag instead of a Vue Router `RouterLink`.
The `external` prop explicitly indicates that the link is external. `<NuxtLink>` will render the link as a standard HTML `<a>` tag. This ensures the link behaves correctly, bypassing Vue Routers logic and directly pointing to the resource.
#### Linking to Static Files
For static files in the `/public` directory, such as PDFs or images, use the `external` prop to ensure the link resolves correctly.
```vue [pages/index.vue]
<template>
<NuxtLink to="/the-important-report.pdf" external>
<NuxtLink to="/example-report.pdf" external>
Download Report
</NuxtLink>
<!-- <a href="/the-important-report.pdf"></a> -->
</template>
```
The external logic is applied by default when using absolute URLs and when providing a `target` prop.
#### Linking to a Cross-App URL
When pointing to a different application on the same domain, using the `external` prop ensures the correct behavior.
```vue [pages/index.vue]
<template>
<NuxtLink to="/another-app" external>
Go to Another App
</NuxtLink>
</template>
```
Using the `external` prop or relying on automatic handling ensures proper navigation, avoids unexpected routing issues, and improves compatibility with static resources or cross-application scenarios.
## External Routing
@ -71,40 +96,126 @@ In this example, we use `<NuxtLink>` component to link to a website.
</template>
```
## `target` and `rel` Attributes
## `rel` and `noRel` Attributes
A `rel` attribute of `noopener noreferrer` is applied by default to absolute links and links that open in new tabs.
A `rel` attribute of `noopener noreferrer` is applied by default to links with a `target` attribute or to absolute links (e.g., links starting with `http://`, `https://`, or `//`).
- `noopener` solves a [security bug](https://mathiasbynens.github.io/rel-noopener/) in older browsers.
- `noreferrer` improves privacy for your users by not sending the `Referer` header to the linked site.
These defaults have no negative impact on SEO and are considered [best practice](https://developer.chrome.com/docs/lighthouse/best-practices/external-anchors-use-rel-noopener).
When you need to overwrite this behavior you can use the `rel` and `noRel` props.
When you need to overwrite this behavior you can use the `rel` or `noRel` props.
```vue [app.vue]
<template>
<NuxtLink to="https://twitter.com/nuxt_js" target="_blank">
<NuxtLink to="https://twitter.com/nuxt_js">
Nuxt Twitter
</NuxtLink>
<!-- <a href="https://twitter.com/nuxt_js" target="_blank" rel="noopener noreferrer">...</a> -->
<!-- <a href="https://twitter.com/nuxt_js" rel="noopener noreferrer">...</a> -->
<NuxtLink to="https://discord.nuxtjs.org" target="_blank" rel="noopener">
<NuxtLink to="https://discord.nuxtjs.org" rel="noopener">
Nuxt Discord
</NuxtLink>
<!-- <a href="https://discord.nuxtjs.org" target="_blank" rel="noopener">...</a> -->
<!-- <a href="https://discord.nuxtjs.org" rel="noopener">...</a> -->
<NuxtLink to="/about" target="_blank">About page</NuxtLink>
<!-- <a href="/about" target="_blank" rel="noopener noreferrer">...</a> -->
</template>
```
A `noRel` prop can be used to prevent the default `rel` attribute from being added to the absolute links.
```vue [app.vue]
<template>
<NuxtLink to="https://github.com/nuxt" no-rel>
Nuxt GitHub
</NuxtLink>
<!-- <a href="https://github.com/nuxt">...</a> -->
<NuxtLink to="/contact" target="_blank">
Contact page opens in another tab
</NuxtLink>
<!-- <a href="/contact" target="_blank" rel="noopener noreferrer">...</a> -->
</template>
```
::note
`noRel` and `rel` cannot be used together. `rel` will be ignored.
::
## Prefetch Links
Nuxt automatically includes smart prefetching. That means it detects when a link is visible (by default), either in the viewport or when scrolling and prefetches the JavaScript for those pages so that they are ready when the user clicks the link. Nuxt only loads the resources when the browser isn't busy and skips prefetching if your connection is offline or if you only have 2g connection.
```vue [pages/index.vue]
<NuxtLink to="/about" no-prefetch>About page not pre-fetched</NuxtLink>
<NuxtLink to="/about" :prefetch="false">About page not pre-fetched</NuxtLink>
```
### Custom Prefetch Triggers
We now support custom prefetch triggers for `<NuxtLink>` after `v3.13.0`. You can use the `prefetchOn` prop to control when to prefetch links.
```vue
<template>
<NuxtLink prefetch-on="visibility">
This will prefetch when it becomes visible (default)
</NuxtLink>
<NuxtLink prefetch-on="interaction">
This will prefetch when hovered or when it gains focus
</NuxtLink>
</template>
```
- `visibility`: Prefetches when the link becomes visible in the viewport. Monitors the element's intersection with the viewport using the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). Prefetching is triggered when the element is scrolled into view.
- `interaction`: Prefetches when the link is hovered or focused. This approach listens for `pointerenter` and `focus` events, proactively prefetching resources when the user indicates intent to interact.
You can also use an object to configure `prefetchOn`:
```vue
<template>
<NuxtLink :prefetch-on="{ interaction: true }">
This will prefetch when hovered or when it gains focus
</NuxtLink>
</template>
```
That you probably don't want both enabled!
```vue
<template>
<NuxtLink :prefetch-on="{ visibility: true, interaction: true }">
This will prefetch when hovered/focus - or when it becomes visible
</NuxtLink>
</template>
```
This configuration will observe when the element enters the viewport and also listen for `pointerenter` and `focus` events. This may result in unnecessary resource usage or redundant prefetching, as both triggers can prefetch the same resource under different conditions.
### Enable Cross-origin Prefetch
To enable cross-origin prefetching, you can set the `crossOriginPrefetch` option in your `nuxt.config`. This will enabled cross-origin prefetch using the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API).
```ts [nuxt.config.ts]
export default defineNuxtConfig({
experimental: {
crossOriginPrefetch: true,
},
})
```
### Disable prefetch globally
It's also possible to enable/disable prefetching all links globally for your app.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
experimental: {
defaults: {
nuxtLink: {
prefetch: false,
},
},
},
})
```
## Props
### RouterLink
@ -113,16 +224,16 @@ When not using `external`, `<NuxtLink>` supports all Vue Router's [`RouterLink`
- `to`: Any URL or a [route location object](https://router.vuejs.org/api/#RouteLocation) from Vue Router
- `custom`: Whether `<NuxtLink>` should wrap its content in an `<a>` element. It allows taking full control of how a link is rendered and how navigation works when it is clicked. Works the same as [Vue Router's `custom` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-custom)
- `exactActiveClass`: A class to apply on exact active links. Works the same as [Vue Router's `exact-active-class` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-exactActiveClass) on internal links. Defaults to Vue Router's default `"router-link-exact-active"`)
- `exactActiveClass`: A class to apply on exact active links. Works the same as [Vue Router's `exactActiveClass` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-exactActiveClass) on internal links. Defaults to Vue Router's default (`"router-link-exact-active"`)
- `activeClass`: A class to apply on active links. Works the same as [Vue Router's `activeClass` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-activeClass) on internal links. Defaults to Vue Router's default (`"router-link-active"`)
- `replace`: Works the same as [Vue Router's `replace` prop](https://router.vuejs.org/api/interfaces/RouteLocationOptions.html#Properties-replace) on internal links
- `ariaCurrentValue`: An `aria-current` attribute value to apply on exact active links. Works the same as [Vue Router's `aria-current-value` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-ariaCurrentValue) on internal links
- `activeClass`: A class to apply on active links. Works the same as [Vue Router's `active-class` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-activeClass) on internal links. Defaults to Vue Router's default (`"router-link-active"`)
- `ariaCurrentValue`: An `aria-current` attribute value to apply on exact active links. Works the same as [Vue Router's `ariaCurrentValue` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-ariaCurrentValue) on internal links
### NuxtLink
- `href`: An alias for `to`. If used with `to`, `href` will be ignored
- `noRel`: If set to `true`, no `rel` attribute will be added to the link
- `external`: Forces the link to be rendered as an `a` tag instead of a Vue Router `RouterLink`.
- `noRel`: If set to `true`, no `rel` attribute will be added to the external link
- `external`: Forces the link to be rendered as an `<a>` tag instead of a Vue Router `RouterLink`.
- `prefetch`: When enabled will prefetch middleware, layouts and payloads (when using [payloadExtraction](/docs/api/nuxt-config#crossoriginprefetch)) of links in the viewport. Used by the experimental [crossOriginPrefetch](/docs/api/nuxt-config#crossoriginprefetch) config.
- `prefetchOn`: Allows custom control of when to prefetch links. Possible options are `interaction` and `visibility` (default). You can also pass an object for full control, for example: `{ interaction: true, visibility: true }`. This prop is only used when `prefetch` is enabled (default) and `noPrefetch` is not set.
- `noPrefetch`: Disables prefetching.
@ -159,6 +270,8 @@ export default defineNuxtConfig({
exactActiveClass: 'router-link-exact-active',
prefetchedClass: undefined, // can be any valid string class name
trailingSlash: undefined // can be 'append' or 'remove'
prefetch: true,
prefetchOn: { visibility: true }
}
}
}

View File

@ -62,7 +62,10 @@ const { data: posts } = await useAsyncData(
## Params
- `key`: a unique key to ensure that data fetching can be properly de-duplicated across requests. If you do not provide a key, then a key that is unique to the file name and line number of the instance of `useAsyncData` will be generated for you.
- `handler`: an asynchronous function that must return a truthy value (for example, it should not be `undefined` or `null`) or the request may be duplicated on the client side
- `handler`: an asynchronous function that must return a truthy value (for example, it should not be `undefined` or `null`) or the request may be duplicated on the client side.
::warning
The `handler` function should be **side-effect free** to ensure predictable behavior during SSR and CSR hydration. If you need to trigger side effects, use the [`callOnce`](/docs/api/utils/call-once) utility to do so.
::
- `options`:
- `server`: whether to fetch the data on the server (defaults to `true`)
- `lazy`: whether to resolve the async function after loading the route, instead of blocking client-side navigation (defaults to `false`)

View File

@ -77,7 +77,7 @@ let previousTodos = []
// Access to the cached value of useAsyncData in todos.vue
const { data: todos } = useNuxtData('todos')
const addTodo = async () => {
async function addTodo () {
return $fetch('/api/addTodo', {
method: 'post',
body: {

View File

@ -15,7 +15,12 @@ Route middleware are navigation guards stored in the [`middleware/`](/docs/guide
## Type
```ts
addRouteMiddleware (name: string | RouteMiddleware, middleware?: RouteMiddleware, options: AddRouteMiddlewareOptions = {})
function addRouteMiddleware (name: string, middleware: RouteMiddleware, options?: AddRouteMiddlewareOptions): void
function addRouteMiddleware (middleware: RouteMiddleware): void
interface AddRouteMiddlewareOptions {
global?: boolean
}
```
## Parameters
@ -42,25 +47,9 @@ An optional `options` argument lets you set the value of `global` to `true` to i
## Examples
### Anonymous Route Middleware
Anonymous route middleware does not have a name. It takes a function as the first argument, making the second `middleware` argument redundant:
```ts [plugins/my-plugin.ts]
export default defineNuxtPlugin(() => {
addRouteMiddleware((to, from) => {
if (to.path === '/forbidden') {
return false
}
})
})
```
### Named Route Middleware
Named route middleware takes a string as the first argument and a function as the second.
When defined in a plugin, it overrides any existing middleware of the same name located in the `middleware/` directory:
Named route middleware is defined by providing a string as the first argument and a function as the second:
```ts [plugins/my-plugin.ts]
export default defineNuxtPlugin(() => {
@ -70,9 +59,23 @@ export default defineNuxtPlugin(() => {
})
```
When defined in a plugin, it overrides any existing middleware of the same name located in the `middleware/` directory.
### Global Route Middleware
Set an optional, third argument `{ global: true }` to indicate whether the route middleware is global:
Global route middleware can be defined in two ways:
- Pass a function directly as the first argument without a name. It will automatically be treated as global middleware and applied on every route change.
```ts [plugins/my-plugin.ts]
export default defineNuxtPlugin(() => {
addRouteMiddleware((to, from) => {
console.log('anonymous global middleware that runs on every route change')
})
})
```
- Set an optional, third argument `{ global: true }` to indicate whether the route middleware is global.
```ts [plugins/my-plugin.ts]
export default defineNuxtPlugin(() => {

View File

@ -4,7 +4,7 @@ description: "Scaffold an entity into your Nuxt application."
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/add.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/add.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: "Analyze the production bundle or your Nuxt application."
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/analyze.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/analyze.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: "Build your Nuxt application."
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/build.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/build.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: 'Remove common generated Nuxt files and caches.'
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/cleanup.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/cleanup.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The dev command starts a development server with hot module replace
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/dev.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/dev.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The devtools command allows you to enable or disable Nuxt DevTools
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/devtools.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/devtools.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: Pre-renders every route of the application and stores the result in
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/generate.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/generate.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The info command logs information about the current or specified Nu
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/info.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/info.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The init command initializes a fresh Nuxt project.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/init.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/init.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: "Search and add modules to your Nuxt application with the command l
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/module/
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/module/
size: xs
---

View File

@ -4,7 +4,7 @@ description: The prepare command creates a .nuxt directory in your application a
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/prepare.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/prepare.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The preview command starts a server to preview your application aft
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/preview.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/preview.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The typecheck command runs vue-tsc to check types throughout your a
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/typecheck.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/typecheck.ts
size: xs
---

View File

@ -4,7 +4,7 @@ description: The upgrade command upgrades Nuxt to the latest version.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/cli/blob/main/src/commands/upgrade.ts
to: https://github.com/nuxt/cli/blob/main/packages/nuxi/src/commands/upgrade.ts
size: xs
---

View File

@ -12,6 +12,9 @@
"build:stub": "pnpm dev:prepare",
"dev": "pnpm play",
"dev:prepare": "pnpm --filter './packages/**' prepack --stub && pnpm --filter './packages/ui-templates' build",
"debug:prepare": "TIMINGS_DEBUG=true pnpm dev:prepare",
"debug:build": "TIMINGS_DEBUG=true pnpm build",
"debug:dev": "rm -rf **/node_modules/.cache/jiti && pnpm nuxi dev",
"lint": "eslint . --cache",
"lint:fix": "eslint . --cache --fix",
"lint:docs": "markdownlint ./docs && case-police 'docs/**/*.md' *.md",
@ -34,12 +37,13 @@
"typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs --languages html"
},
"resolutions": {
"@nuxt/cli": "3.20.0",
"@nuxt/kit": "workspace:*",
"@nuxt/rspack-builder": "workspace:*",
"@nuxt/schema": "workspace:*",
"@nuxt/vite-builder": "workspace:*",
"@nuxt/webpack-builder": "workspace:*",
"@types/node": "22.10.5",
"@types/node": "22.10.7",
"@unhead/schema": "2.0.0-alpha.3",
"@unhead/vue": "2.0.0-alpha.3",
"@unhead/shared": "2.0.0-alpha.3",
@ -47,25 +51,27 @@
"@vue/compiler-dom": "3.5.13",
"@vue/shared": "3.5.13",
"c12": "2.0.1",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"jiti": "2.4.2",
"magic-string": "^0.30.17",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nitro": "npm:nitro-nightly@3.0.0-beta-28938837.19ec5395",
"nuxt": "workspace:*",
"ohash": "1.1.4",
"postcss": "8.5.0",
"rollup": "4.30.1",
"postcss": "8.5.1",
"rollup": "4.31.0",
"send": ">=1.1.0",
"typescript": "5.7.3",
"ufo": "1.5.4",
"unbuild": "3.3.1",
"unhead": "2.0.0-alpha.3",
"unimport": "3.14.5",
"vite": "6.0.7",
"unimport": "3.14.6",
"vite": "6.0.9",
"vue": "3.5.13"
},
"devDependencies": {
"@arethetypeswrong/cli": "0.17.3",
"@babel/core": "7.26.0",
"@babel/helper-plugin-utils": "7.26.5",
"@nuxt/cli": "3.20.0",
"@nuxt/eslint-config": "0.7.5",
"@nuxt/kit": "workspace:*",
@ -73,12 +79,15 @@
"@nuxt/test-utils": "3.15.4",
"@nuxt/webpack-builder": "workspace:*",
"@testing-library/vue": "8.1.0",
"@types/node": "22.10.6",
"@types/babel__core": "7.20.5",
"@types/babel__helper-plugin-utils": "7.10.3",
"@types/node": "22.10.7",
"@types/semver": "7.5.8",
"@unhead/schema": "2.0.0-alpha.3",
"@unhead/vue": "2.0.0-alpha.3",
"@vitest/coverage-v8": "2.1.8",
"@vitest/coverage-v8": "3.0.2",
"@vue/test-utils": "2.4.6",
"acorn": "8.14.0",
"autoprefixer": "10.4.20",
"case-police": "0.7.2",
"changelogen": "0.5.7",
@ -90,28 +99,33 @@
"eslint-plugin-no-only-tests": "3.3.0",
"eslint-plugin-perfectionist": "4.6.0",
"eslint-typegen": "1.0.0",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"happy-dom": "16.5.3",
"estree-walker": "3.0.3",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"happy-dom": "16.6.0",
"installed-check": "9.3.0",
"jiti": "2.4.2",
"knip": "5.42.0",
"knip": "5.42.2",
"magic-string": "0.30.17",
"markdownlint-cli": "0.43.0",
"memfs": "4.17.0",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nitro": "npm:nitro-nightly@3.0.0-beta-28938837.19ec5395",
"nuxt": "workspace:*",
"nuxt-content-twoslash": "0.1.2",
"ofetch": "1.4.1",
"pathe": "2.0.1",
"pathe": "2.0.2",
"pkg-pr-new": "0.0.39",
"playwright-core": "1.49.1",
"rollup": "4.31.0",
"semver": "7.6.3",
"sherif": "1.1.1",
"std-env": "3.8.0",
"tinyexec": "0.3.2",
"tinyglobby": "0.2.10",
"ts-blank-space": "0.5.0",
"typescript": "5.7.3",
"ufo": "1.5.4",
"vitest": "2.1.8",
"unbuild": "3.3.1",
"vitest": "3.0.2",
"vitest-environment-nuxt": "1.0.1",
"vue": "3.5.13",
"vue-tsc": "2.2.0",

View File

@ -1,10 +1,17 @@
import { defineBuildConfig } from 'unbuild'
import { addRollupTimingsPlugin, stubOptions } from '../../debug/build-config'
export default defineBuildConfig({
declaration: true,
entries: [
'src/index',
],
stubOptions,
hooks: {
'rollup:options' (ctx, options) {
addRollupTimingsPlugin(options)
},
},
externals: [
'@rspack/core',
'@nuxt/schema',

View File

@ -34,27 +34,28 @@
"destr": "^2.0.3",
"errx": "^0.1.0",
"globby": "^14.0.2",
"ignore": "^7.0.1",
"ignore": "^7.0.3",
"jiti": "^2.4.2",
"klona": "^2.0.6",
"mlly": "^1.7.4",
"ohash": "^1.1.4",
"pathe": "^2.0.1",
"pkg-types": "^1.3.0",
"pathe": "^2.0.2",
"pkg-types": "^1.3.1",
"scule": "^1.3.0",
"semver": "^7.6.3",
"std-env": "^3.8.0",
"ufo": "^1.5.4",
"unctx": "^2.4.1",
"unimport": "^3.14.5",
"unimport": "^3.14.6",
"untyped": "^1.5.2"
},
"devDependencies": {
"@rspack/core": "1.1.8",
"@types/semver": "7.5.8",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nitro": "npm:nitro-nightly@3.0.0-beta-28938837.19ec5395",
"unbuild": "3.3.1",
"vite": "6.0.7",
"vitest": "2.1.8",
"vite": "6.0.9",
"vitest": "3.0.2",
"webpack": "5.97.1"
},
"engines": {

View File

@ -114,7 +114,7 @@ export function addWebpackPlugin (pluginOrGetter: WebpackPluginInstance | Webpac
const method: 'push' | 'unshift' = options?.prepend ? 'unshift' : 'push'
const plugin = typeof pluginOrGetter === 'function' ? pluginOrGetter() : pluginOrGetter
config.plugins = config.plugins || []
config.plugins ||= []
config.plugins[method](...toArray(plugin))
}, options)
}
@ -126,7 +126,7 @@ export function addRspackPlugin (pluginOrGetter: RspackPluginInstance | RspackPl
const method: 'push' | 'unshift' = options?.prepend ? 'unshift' : 'push'
const plugin = typeof pluginOrGetter === 'function' ? pluginOrGetter() : pluginOrGetter
config.plugins = config.plugins || []
config.plugins ||= []
config.plugins[method](...toArray(plugin))
}, options)
}
@ -139,7 +139,7 @@ export function addVitePlugin (pluginOrGetter: VitePlugin | VitePlugin[] | (() =
const method: 'push' | 'unshift' = options?.prepend ? 'unshift' : 'push'
const plugin = typeof pluginOrGetter === 'function' ? pluginOrGetter() : pluginOrGetter
config.plugins = config.plugins || []
config.plugins ||= []
config.plugins[method](...toArray(plugin))
}, options)
}

View File

@ -11,7 +11,7 @@ import { MODE_RE } from './utils'
export async function addComponentsDir (dir: ComponentsDir, opts: { prepend?: boolean } = {}) {
const nuxt = useNuxt()
await assertNuxtCompatibility({ nuxt: '>=2.13' }, nuxt)
nuxt.options.components = nuxt.options.components || []
nuxt.options.components ||= []
dir.priority ||= 0
nuxt.hook('components:dirs', (dirs) => { dirs[opts.prepend ? 'unshift' : 'push'](dir) })
}
@ -26,7 +26,7 @@ export type AddComponentOptions = { name: string, filePath: string } & Partial<E
export async function addComponent (opts: AddComponentOptions) {
const nuxt = useNuxt()
await assertNuxtCompatibility({ nuxt: '>=2.13' }, nuxt)
nuxt.options.components = nuxt.options.components || []
nuxt.options.components ||= []
if (!opts.mode) {
const [, mode = 'all'] = opts.filePath.match(MODE_RE) || []

View File

@ -3,12 +3,14 @@ import ignore from 'ignore'
import { join, relative, resolve } from 'pathe'
import { tryUseNuxt } from './context'
export function createIsIgnored (nuxt = tryUseNuxt()) {
return (pathname: string, stats?: unknown) => isIgnored(pathname, stats, nuxt)
}
/**
* Return a filter function to filter an array of paths
*/
export function isIgnored (pathname: string): boolean {
const nuxt = tryUseNuxt()
export function isIgnored (pathname: string, _stats?: unknown, nuxt = tryUseNuxt()): boolean {
// Happens with CLI reloads
if (!nuxt) {
return false

View File

@ -19,7 +19,7 @@ export { assertNuxtCompatibility, checkNuxtCompatibility, getNuxtVersion, hasNux
export { addComponent, addComponentsDir } from './components'
export type { AddComponentOptions } from './components'
export { nuxtCtx, tryUseNuxt, useNuxt } from './context'
export { isIgnored, resolveIgnorePatterns } from './ignore'
export { createIsIgnored, isIgnored, resolveIgnorePatterns } from './ignore'
export { addLayout } from './layout'
export { addRouteMiddleware, extendPages, extendRouteRules } from './pages'
export type { AddRouteMiddlewareOptions, ExtendRouteRulesOptions } from './pages'

View File

@ -58,7 +58,7 @@ export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise<Nuxt
const processedLayers = new Set<string>()
for (const layer of layers) {
// Resolve `rootDir` & `srcDir` of layers
layer.config = layer.config || {}
layer.config ||= {}
layer.config.rootDir = layer.config.rootDir ?? layer.cwd!
// Only process/resolve layers once

View File

@ -16,7 +16,7 @@ export interface LoadNuxtOptions extends LoadNuxtConfigOptions {
export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
// Backward compatibility
opts.cwd = resolve(opts.cwd || (opts as any).rootDir /* backwards compat */ || '.')
opts.overrides = opts.overrides || (opts as any).config as NuxtConfig /* backwards compat */ || {}
opts.overrides ||= (opts as any).config as NuxtConfig /* backwards compat */ || {}
// Apply dev as config override
opts.overrides.dev = !!opts.dev

View File

@ -87,7 +87,7 @@ function _defineNuxtModule<
// Avoid duplicate installs
const uniqueKey = module.meta.name || module.meta.configKey
if (uniqueKey) {
nuxt.options._requiredModules = nuxt.options._requiredModules || {}
nuxt.options._requiredModules ||= {}
if (nuxt.options._requiredModules[uniqueKey]) {
return false
}

View File

@ -19,11 +19,11 @@ export async function installModule<
> (moduleToInstall: T, inlineOptions?: [Config] extends [never] ? any : Config[1], nuxt: Nuxt = useNuxt()) {
const { nuxtModule, buildTimeModuleMeta, resolvedModulePath } = await loadNuxtModuleInstance(moduleToInstall, nuxt)
const localLayerModuleDirs = new Set<string>()
const localLayerModuleDirs: string[] = []
for (const l of nuxt.options._layers) {
const srcDir = l.config.srcDir || l.cwd
if (!NODE_MODULES_RE.test(srcDir)) {
localLayerModuleDirs.add(resolve(srcDir, l.config?.dir?.modules || 'modules'))
localLayerModuleDirs.push(resolve(srcDir, l.config?.dir?.modules || 'modules').replace(/\/?$/, '/'))
}
}
@ -38,13 +38,13 @@ export async function installModule<
const parsed = parseNodeModulePath(modulePath)
const moduleRoot = parsed.dir ? parsed.dir + parsed.name : modulePath
nuxt.options.build.transpile.push(normalizeModuleTranspilePath(moduleRoot))
const directory = parsed.dir ? moduleRoot : getDirectory(modulePath)
if (directory !== moduleToInstall && !localLayerModuleDirs.has(directory)) {
const directory = (parsed.dir ? moduleRoot : getDirectory(modulePath)).replace(/\/?$/, '/')
if (directory !== moduleToInstall && !localLayerModuleDirs.some(dir => directory.startsWith(dir))) {
nuxt.options.modulesDir.push(resolve(directory, 'node_modules'))
}
}
nuxt.options._installedModules = nuxt.options._installedModules || []
nuxt.options._installedModules ||= []
const entryPath = typeof moduleToInstall === 'string' ? resolveAlias(moduleToInstall) : undefined
if (typeof moduleToInstall === 'string' && entryPath !== moduleToInstall) {

View File

@ -40,7 +40,7 @@ export function addDevServerHandler (handler: NitroDevEventHandler) {
*/
export function addServerPlugin (plugin: string) {
const nuxt = useNuxt()
nuxt.options.nitro.plugins = nuxt.options.nitro.plugins || []
nuxt.options.nitro.plugins ||= []
nuxt.options.nitro.plugins.push(normalize(plugin))
}
@ -89,8 +89,8 @@ export function useNitro (): Nitro {
export function addServerImports (imports: Import[]) {
const nuxt = useNuxt()
nuxt.hook('nitro:config', (config) => {
config.imports = config.imports || {}
config.imports.imports = config.imports.imports || []
config.imports ||= {}
config.imports.imports ||= []
config.imports.imports.push(...imports)
})
}
@ -102,8 +102,8 @@ export function addServerImportsDir (dirs: string | string[], opts: { prepend?:
const nuxt = useNuxt()
const _dirs = toArray(dirs)
nuxt.hook('nitro:config', (config) => {
config.imports = config.imports || {}
config.imports.dirs = config.imports.dirs || []
config.imports ||= {}
config.imports.dirs ||= []
config.imports.dirs[opts.prepend ? 'unshift' : 'push'](..._dirs)
})
}
@ -115,7 +115,7 @@ export function addServerImportsDir (dirs: string | string[], opts: { prepend?:
export function addServerScanDir (dirs: string | string[], opts: { prepend?: boolean } = {}) {
const nuxt = useNuxt()
nuxt.hook('nitro:config', (config) => {
config.scanDirs = config.scanDirs || []
config.scanDirs ||= []
for (const dir of toArray(dirs)) {
config.scanDirs[opts.prepend ? 'unshift' : 'push'](dir)

View File

@ -20,9 +20,7 @@ export interface ExtendRouteRulesOptions {
export function extendRouteRules (route: string, rule: NitroRouteConfig, options: ExtendRouteRulesOptions = {}) {
const nuxt = useNuxt()
for (const opts of [nuxt.options, nuxt.options.nitro]) {
if (!opts.routeRules) {
opts.routeRules = {}
}
opts.routeRules ||= {}
opts.routeRules[route] = options.override
? defu(rule, opts.routeRules[route])
: defu(opts.routeRules[route], rule)

View File

@ -1,13 +1,19 @@
import { existsSync } from 'node:fs'
import { isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
import { normalize } from 'pathe'
import type { NuxtPlugin, NuxtPluginTemplate } from '@nuxt/schema'
import { useNuxt } from './context'
import { resolvePathSync } from 'mlly'
import { isWindows } from 'std-env'
import { MODE_RE, filterInPlace } from './utils'
import { tryUseNuxt, useNuxt } from './context'
import { addTemplate } from './template'
import { resolveAlias } from './resolve'
import { MODE_RE } from './utils'
/**
* Normalize a nuxt plugin object
*/
const pluginSymbol = Symbol.for('nuxt plugin')
export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin {
// Normalize src
if (typeof plugin === 'string') {
@ -16,6 +22,10 @@ export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin {
plugin = { ...plugin }
}
if (pluginSymbol in plugin) {
return plugin
}
if (!plugin.src) {
throw new Error('Invalid plugin. src option is required: ' + JSON.stringify(plugin))
}
@ -23,6 +33,14 @@ export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin {
// Normalize full path to plugin
plugin.src = normalize(resolveAlias(plugin.src))
if (!existsSync(plugin.src) && isAbsolute(plugin.src)) {
try {
plugin.src = resolvePathSync(isWindows ? pathToFileURL(plugin.src).href : plugin.src, { extensions: tryUseNuxt()?.options.extensions })
} catch {
// ignore errors as the file may be in the nuxt vfs
}
}
// Normalize mode
if (plugin.ssr) {
plugin.mode = 'server'
@ -32,6 +50,9 @@ export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin {
plugin.mode = mode as 'all' | 'client' | 'server'
}
// @ts-expect-error not adding symbol to types to avoid conflicts
plugin[pluginSymbol] = true
return plugin
}
@ -61,7 +82,7 @@ export function addPlugin (_plugin: NuxtPlugin | string, opts: AddPluginOptions
const plugin = normalizePlugin(_plugin)
// Remove any existing plugin with the same src
nuxt.options.plugins = nuxt.options.plugins.filter(p => normalizePlugin(p).src !== plugin.src)
filterInPlace(nuxt.options.plugins, p => normalizePlugin(p).src !== plugin.src)
// Prepend to array by default to be before user provided plugins since is usually used by modules
nuxt.options.plugins[opts.append ? 'push' : 'unshift'](plugin)

View File

@ -8,6 +8,7 @@ import type { TSConfig } from 'pkg-types'
import { gte } from 'semver'
import { readPackageJSON } from 'pkg-types'
import { filterInPlace } from './utils'
import { tryResolveModule } from './internal/esm'
import { getDirectory } from './module/install'
import { tryUseNuxt, useNuxt } from './context'
@ -23,7 +24,7 @@ export function addTemplate<T> (_template: NuxtTemplate<T> | string) {
const template = normalizeTemplate(_template)
// Remove any existing template with the same destination path
nuxt.options.build.templates = nuxt.options.build.templates.filter(p => normalizeTemplate(p).dst !== template.dst)
filterInPlace(nuxt.options.build.templates, p => normalizeTemplate(p).dst !== template.dst)
// Add to templates array
nuxt.options.build.templates.push(template)
@ -229,9 +230,9 @@ export async function _generateTypes (nuxt: Nuxt) {
? resolve(nuxt.options.buildDir, tsConfig.compilerOptions!.baseUrl)
: nuxt.options.buildDir
tsConfig.compilerOptions = tsConfig.compilerOptions || {}
tsConfig.compilerOptions.paths = tsConfig.compilerOptions.paths || {}
tsConfig.include = tsConfig.include || []
tsConfig.compilerOptions ||= {}
tsConfig.compilerOptions.paths ||= {}
tsConfig.include ||= []
for (const alias in aliases) {
if (excludedAlias.some(re => re.test(alias))) {

View File

@ -3,4 +3,19 @@ export function toArray<T> (value: T | T[]): T[] {
return Array.isArray(value) ? value : [value]
}
/**
* Filter out items from an array in place. This function mutates the array.
* `predicate` get through the array from the end to the start for performance.
*
* This function should be faster than `Array.prototype.filter` on large arrays.
*/
export function filterInPlace<T> (array: T[], predicate: (item: T, index: number, arr: T[]) => unknown) {
for (let i = array.length; i--; i >= 0) {
if (!predicate(array[i]!, i, array)) {
array.splice(i, 1)
}
}
return array
}
export const MODE_RE = /\.(server|client)(\.\w+)*$/

View File

@ -1,5 +1,6 @@
import type { BuildEntry } from 'unbuild'
import { defineBuildConfig } from 'unbuild'
import { addRollupTimingsPlugin, stubOptions } from '../../debug/build-config'
export default defineBuildConfig({
declaration: true,
@ -16,10 +17,14 @@ export default defineBuildConfig({
'pages',
].map(name => ({ input: `src/${name}/runtime/`, outDir: `dist/${name}/runtime`, format: 'esm', ext: 'js' } as BuildEntry)),
],
stubOptions,
hooks: {
'mkdist:entry:options' (_ctx, _entry, mkdistOptions) {
mkdistOptions.addRelativeDeclarationExtensions = true
},
'rollup:options' (ctx, options) {
addRollupTimingsPlugin(options)
},
},
dependencies: [
'@nuxt/cli',

View File

@ -87,35 +87,35 @@
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3",
"globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"hookable": "^5.5.3",
"ignore": "^7.0.1",
"ignore": "^7.0.3",
"impound": "^0.2.0",
"jiti": "^2.4.2",
"klona": "^2.0.6",
"knitwork": "^1.2.0",
"magic-string": "^0.30.17",
"mlly": "^1.7.4",
"nanotar": "^0.1.1",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nypm": "^0.4.1",
"nanotar": "^0.2.0",
"nitro": "npm:nitro-nightly@3.0.0-beta-28938837.19ec5395",
"nypm": "^0.5.0",
"ofetch": "^1.4.1",
"ohash": "^1.1.4",
"pathe": "^2.0.1",
"pathe": "^2.0.2",
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.3.0",
"pkg-types": "^1.3.1",
"radix3": "^1.1.2",
"scule": "^1.3.0",
"semver": "^7.6.3",
"std-env": "^3.8.0",
"strip-literal": "^2.1.1",
"strip-literal": "^3.0.0",
"tinyglobby": "0.2.10",
"ufo": "^1.5.4",
"ultrahtml": "^1.5.3",
"uncrypto": "^0.1.3",
"unctx": "^2.4.1",
"unenv": "^1.10.0",
"unimport": "^3.14.5",
"unimport": "^3.14.6",
"unplugin": "^2.1.2",
"unplugin-vue-router": "^0.10.9",
"unstorage": "^1.14.4",
@ -132,8 +132,8 @@
"@vitejs/plugin-vue": "5.2.1",
"@vue/compiler-sfc": "3.5.13",
"unbuild": "3.3.1",
"vite": "6.0.7",
"vitest": "2.1.8"
"vite": "6.0.9",
"vitest": "3.0.2"
},
"peerDependencies": {
"@parcel/watcher": "^2.1.0",

View File

@ -14,7 +14,7 @@ export default defineComponent({
inheritAttrs: false,
props: ['fallback', 'placeholder', 'placeholderTag', 'fallbackTag'],
setup (_, { slots, attrs }) {
setup (props, { slots, attrs }) {
const mounted = ref(false)
onMounted(() => { mounted.value = true })
// Bail out of checking for pages/layouts as they might be included under `<ClientOnly>` 🤷‍♂️
@ -24,10 +24,10 @@ export default defineComponent({
nuxtApp._isNuxtLayoutUsed = true
}
provide(clientOnlySymbol, true)
return (props: any) => {
return () => {
if (mounted.value) { return slots.default?.() }
const slot = slots.fallback || slots.placeholder
if (slot) { return slot() }
if (slot) { return h(slot) }
const fallbackStr = props.fallback || props.placeholder || ''
const fallbackTag = props.fallbackTag || props.placeholderTag || 'span'
return createElementBlock(fallbackTag, attrs, fallbackStr)
@ -95,7 +95,7 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
if (isPromise(setupState)) {
return Promise.resolve(setupState).then((setupState) => {
if (typeof setupState !== 'function') {
setupState = setupState || {}
setupState ||= {}
setupState.mounted$ = mounted$
return setupState
}

View File

@ -325,10 +325,13 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
const elRef = import.meta.server ? undefined : (ref: any) => { el!.value = props.custom ? ref?.$el?.nextElementSibling : ref?.$el }
function shouldPrefetch (mode: 'visibility' | 'interaction') {
if (import.meta.server) { return }
return !prefetched.value && (typeof props.prefetchOn === 'string' ? props.prefetchOn === mode : (props.prefetchOn?.[mode] ?? options.prefetchOn?.[mode])) && (props.prefetch ?? options.prefetch) !== false && props.noPrefetch !== true && props.target !== '_blank' && !isSlowConnection()
}
async function prefetch (nuxtApp = useNuxtApp()) {
if (import.meta.server) { return }
if (prefetched.value) { return }
prefetched.value = true
@ -395,6 +398,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
// `custom` API cannot support fallthrough attributes as the slot
// may render fragment or text root nodes (#14897, #19375)
if (!props.custom) {
if (import.meta.client) {
if (shouldPrefetch('interaction')) {
routerLinkProps.onPointerenter = prefetch.bind(null, undefined)
routerLinkProps.onFocus = prefetch.bind(null, undefined)
@ -402,6 +406,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
if (prefetched.value) {
routerLinkProps.class = props.prefetchedClass || options.prefetchedClass
}
}
routerLinkProps.rel = props.rel || undefined
}

View File

@ -37,7 +37,7 @@ export async function callOnce (...args: any): Promise<void> {
return
}
nuxtApp._once = nuxtApp._once || {}
nuxtApp._once ||= {}
nuxtApp._once[_key] = nuxtApp._once[_key] || fn() || true
await nuxtApp._once[_key]
nuxtApp.payload.once.add(_key)

View File

@ -250,7 +250,7 @@ export default defineNuxtModule<ComponentsOptions>({
// TODO: refactor this
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
config.plugins = config.plugins || []
config.plugins ||= []
if (isClient && selectiveClient) {
writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}')
@ -275,7 +275,7 @@ export default defineNuxtModule<ComponentsOptions>({
nuxt.hook(key, (configs) => {
configs.forEach((config) => {
const mode = config.name === 'client' ? 'client' : 'server'
config.plugins = config.plugins || []
config.plugins ||= []
if (mode !== 'server') {
writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}')

View File

@ -58,7 +58,7 @@ export function TransformPlugin (nuxt: Nuxt, options: TransformPluginOptions) {
enforce: 'post',
transformInclude (id) {
id = normalize(id)
return id.startsWith('virtual:') || id.startsWith('\0virtual:') || id.startsWith(nuxt.options.buildDir) || !isIgnored(id)
return id.startsWith('virtual:') || id.startsWith('\0virtual:') || id.startsWith(nuxt.options.buildDir) || !isIgnored(id, undefined, nuxt)
},
async transform (code, id) {
// Virtual component wrapper

View File

@ -57,6 +57,7 @@ export async function generateApp (nuxt: Nuxt, app: NuxtApp, options: { filter?:
const templateContext = { nuxt, app }
const writes: Array<() => void> = []
const dirs = new Set<string>()
const changedTemplates: Array<ResolvedNuxtTemplate<any>> = []
const FORWARD_SLASH_RE = /\//g
async function processTemplate (template: ResolvedNuxtTemplate) {
@ -92,10 +93,8 @@ export async function generateApp (nuxt: Nuxt, app: NuxtApp, options: { filter?:
}
if (template.modified && template.write) {
writes.push(() => {
mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, contents, 'utf8')
})
dirs.add(dirname(fullPath))
writes.push(() => writeFileSync(fullPath, contents, 'utf8'))
}
}
@ -104,7 +103,12 @@ export async function generateApp (nuxt: Nuxt, app: NuxtApp, options: { filter?:
// Write template files in single synchronous step to avoid (possible) additional
// runtime overhead of cascading HMRs from vite/webpack
for (const write of writes) { write() }
for (const dir of dirs) {
mkdirSync(dir, { recursive: true })
}
for (const write of writes) {
write()
}
if (changedTemplates.length) {
await nuxt.callHook('app:templatesGenerated', app, changedTemplates, options)

View File

@ -1,7 +1,7 @@
import type { EventType } from '@parcel/watcher'
import type { FSWatcher } from 'chokidar'
import { watch as chokidarWatch } from 'chokidar'
import { importModule, isIgnored, tryResolveModule, useNuxt } from '@nuxt/kit'
import { createIsIgnored, importModule, isIgnored, tryResolveModule, useNuxt } from '@nuxt/kit'
import { debounce } from 'perfect-debounce'
import { normalize, relative, resolve } from 'pathe'
import type { Nuxt, NuxtBuilder } from 'nuxt/schema'
@ -63,8 +63,11 @@ export async function build (nuxt: Nuxt) {
return
}
if (nuxt.options.dev) {
if (nuxt.options.dev && !nuxt.options.test) {
nuxt.hooks.hookOnce('build:done', () => {
checkForExternalConfigurationFiles()
.catch(e => logger.warn('Problem checking for external configuration files.', e))
})
}
await bundle(nuxt)
@ -97,14 +100,12 @@ async function watch (nuxt: Nuxt) {
function createWatcher () {
const nuxt = useNuxt()
const isIgnored = createIsIgnored(nuxt)
const watcher = chokidarWatch(nuxt.options._layers.map(i => i.config.srcDir as string).filter(Boolean), {
...nuxt.options.watchers.chokidar,
ignoreInitial: true,
ignored: [
isIgnored,
'node_modules',
],
ignored: [isIgnored, /[\\/]node_modules[\\/]/],
})
watcher.on('all', (event, path) => {
@ -118,6 +119,7 @@ function createWatcher () {
function createGranularWatcher () {
const nuxt = useNuxt()
const isIgnored = createIsIgnored(nuxt)
if (nuxt.options.debug) {
// eslint-disable-next-line no-console
@ -136,7 +138,7 @@ function createGranularWatcher () {
}
for (const dir of pathsToWatch) {
pending++
const watcher = chokidarWatch(dir, { ...nuxt.options.watchers.chokidar, ignoreInitial: false, depth: 0, ignored: [isIgnored, '**/node_modules'] })
const watcher = chokidarWatch(dir, { ...nuxt.options.watchers.chokidar, ignoreInitial: false, depth: 0, ignored: [isIgnored, /[\\/]node_modules[\\/]/] })
const watchers: Record<string, FSWatcher> = {}
watcher.on('all', (event, path) => {

View File

@ -2,7 +2,7 @@ import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises
import type { FileHandle } from 'node:fs/promises'
import { resolve } from 'node:path'
import { existsSync } from 'node:fs'
import { isIgnored } from '@nuxt/kit'
import { createIsIgnored } from '@nuxt/kit'
import type { Nuxt, NuxtConfig, NuxtConfigLayer } from '@nuxt/schema'
import { hash, murmurHash, objectHash } from 'ohash'
import { glob } from 'tinyglobby'
@ -119,6 +119,7 @@ async function getHashes (nuxt: Nuxt, options: GetHashOptions): Promise<Hashes>
data: murmurHash(f.data as any /* ArrayBuffer */),
}))
const isIgnored = createIsIgnored(nuxt)
const sourceFiles = await readFilesRecursive(options.cwd(layer), {
shouldIgnore: isIgnored, // TODO: Validate if works with absolute paths
cwd: nuxt.options.rootDir,

View File

@ -239,7 +239,11 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
// Resolve user-provided paths
nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!)
nitroConfig.ignore = [...(nitroConfig.ignore || []), ...resolveIgnorePatterns(nitroConfig.srcDir), `!${join(nuxt.options.buildDir, 'dist/client', nuxt.options.app.buildAssetsDir, '**/*')}`]
nitroConfig.ignore ||= []
nitroConfig.ignore.push(
...resolveIgnorePatterns(nitroConfig.srcDir),
`!${join(nuxt.options.buildDir, 'dist/client', nuxt.options.app.buildAssetsDir, '**/*')}`,
)
// Resolve aliases in user-provided input - so `~/server/test` will work
nitroConfig.plugins = nitroConfig.plugins?.map(plugin => plugin ? resolveAlias(plugin, nuxt.options.alias) : plugin)
@ -411,14 +415,16 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
const basePath = nitroConfig.typescript!.tsConfig!.compilerOptions?.baseUrl ? resolve(nuxt.options.buildDir, nitroConfig.typescript!.tsConfig!.compilerOptions?.baseUrl) : nuxt.options.buildDir
const aliases = nitroConfig.alias!
const tsConfig = nitroConfig.typescript!.tsConfig!
tsConfig.compilerOptions = tsConfig.compilerOptions || {}
tsConfig.compilerOptions.paths = tsConfig.compilerOptions.paths || {}
tsConfig.compilerOptions ||= {}
tsConfig.compilerOptions.paths ||= {}
for (const _alias in aliases) {
const alias = _alias as keyof typeof aliases
if (excludedAlias.some(pattern => typeof pattern === 'string' ? alias === pattern : pattern.test(alias))) {
continue
}
if (alias in tsConfig.compilerOptions.paths) { continue }
if (alias in tsConfig.compilerOptions.paths) {
continue
}
const absolutePath = resolve(basePath, aliases[alias]!)
const stats = await fsp.stat(absolutePath).catch(() => null /* file does not exist */)
@ -532,7 +538,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
await writeTypes(nitro)
}
// Exclude nitro output dir from typescript
opts.tsConfig.exclude = opts.tsConfig.exclude || []
opts.tsConfig.exclude ||= []
opts.tsConfig.exclude.push(relative(nuxt.options.buildDir, resolve(nuxt.options.rootDir, nitro.options.output.dir)))
opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/nitro.d.ts') })
})

View File

@ -81,7 +81,7 @@ const nightlies = {
'@nuxt/kit': '@nuxt/kit-nightly',
}
const keyDependencies = [
export const keyDependencies = [
'@nuxt/kit',
'@nuxt/schema',
]
@ -404,8 +404,11 @@ async function initNuxt (nuxt: Nuxt) {
...nuxt.options._layers.filter(i => i.cwd.includes('node_modules')).map(i => i.cwd as string),
)
// Ensure we can resolve dependencies within layers
nuxt.options.modulesDir.push(...nuxt.options._layers.map(l => resolve(l.cwd, 'node_modules')))
// Ensure we can resolve dependencies within layers - filtering out local `~/layers` directories
const locallyScannedLayersDirs = nuxt.options._layers.map(l => resolve(l.cwd, 'layers').replace(/\/?$/, '/'))
nuxt.options.modulesDir.push(...nuxt.options._layers
.filter(l => l.cwd !== nuxt.options.rootDir && locallyScannedLayersDirs.every(dir => !l.cwd.startsWith(dir)))
.map(l => resolve(l.cwd, 'node_modules')))
// Init user modules
await nuxt.callHook('modules:before')
@ -802,8 +805,13 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
const nuxt = createNuxt(options)
if (nuxt.options.dev && !nuxt.options.test) {
nuxt.hooks.hookOnce('build:done', () => {
for (const dep of keyDependencies) {
checkDependencyVersion(dep, nuxt._version)
.catch(e => logger.warn(`Problem checking \`${dep}\` version.`, e))
}
})
}
// We register hooks layer-by-layer so any overrides need to be registered separately
@ -822,7 +830,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
return nuxt
}
async function checkDependencyVersion (name: string, nuxtVersion: string): Promise<void> {
export async function checkDependencyVersion (name: string, nuxtVersion: string): Promise<void> {
const path = await resolvePath(name, { fallbackToOriginal: true }).catch(() => null)
if (!path || path === name) { return }

View File

@ -38,25 +38,25 @@ export const VirtualFSPlugin = (nuxt: Nuxt, options: VirtualFSPluginOptions) =>
const resolvedId = resolveWithExt(id)
if (resolvedId) {
return PREFIX + resolvedId
return PREFIX + encodeURIComponent(resolvedId)
}
if (importer && RELATIVE_ID_RE.test(id)) {
const path = resolve(dirname(withoutPrefix(importer)), id)
const path = resolve(dirname(withoutPrefix(decodeURIComponent(importer))), id)
const resolved = resolveWithExt(path)
if (resolved) {
return PREFIX + resolved
return PREFIX + encodeURIComponent(resolved)
}
}
},
loadInclude (id) {
return id.startsWith(PREFIX) && withoutPrefix(id) in nuxt.vfs
return id.startsWith(PREFIX) && withoutPrefix(decodeURIComponent(id)) in nuxt.vfs
},
load (id) {
return {
code: nuxt.vfs[withoutPrefix(id)] || '',
code: nuxt.vfs[withoutPrefix(decodeURIComponent(id))] || '',
map: null,
}
},

View File

@ -2,7 +2,7 @@ import { defineEventHandler, getRequestHeader } from 'h3'
export default defineEventHandler((event) => {
if (getRequestHeader(event, 'x-nuxt-no-ssr')) {
event.context.nuxt = event.context.nuxt || {}
event.context.nuxt ||= {}
event.context.nuxt.noSSR = true
}
})

View File

@ -486,8 +486,8 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
}
// TODO: remove for v4
islandHead.link = islandHead.link || []
islandHead.style = islandHead.style || []
islandHead.link ||= []
islandHead.style ||= []
const islandResponse: NuxtIslandResponse = {
id: islandContext.id,

View File

@ -5,11 +5,8 @@ import { resolve } from 'pathe'
import { watch } from 'chokidar'
import { defu } from 'defu'
import { debounce } from 'perfect-debounce'
import { createResolver, defineNuxtModule, importModule, tryResolveModule } from '@nuxt/kit'
import {
generateTypes,
resolveSchema as resolveUntypedSchema,
} from 'untyped'
import { createIsIgnored, createResolver, defineNuxtModule, importModule, tryResolveModule } from '@nuxt/kit'
import { generateTypes, resolveSchema as resolveUntypedSchema } from 'untyped'
import type { Schema, SchemaDefinition } from 'untyped'
import untypedPlugin from 'untyped/babel-plugin'
import { createJiti } from 'jiti'
@ -71,11 +68,17 @@ export default defineNuxtModule({
logger.warn('Falling back to `chokidar` as `@parcel/watcher` cannot be resolved in your project.')
}
const filesToWatch = await Promise.all(nuxt.options._layers.map(layer =>
resolver.resolve(layer.config.rootDir, 'nuxt.schema.*'),
))
const watcher = watch(filesToWatch, {
const isIgnored = createIsIgnored(nuxt)
const dirsToWatch = nuxt.options._layers.map(layer => resolver.resolve(layer.config.rootDir))
const SCHEMA_RE = /(?:^|\/)nuxt.schema.\w+$/
const watcher = watch(dirsToWatch, {
...nuxt.options.watchers.chokidar,
depth: 1,
ignored: [
(path, stats) => (stats && !stats.isFile()) || !SCHEMA_RE.test(path),
isIgnored,
/[\\/]node_modules[\\/]/,
],
ignoreInitial: true,
})
watcher.on('all', onChange)

View File

@ -68,7 +68,7 @@ export const clientPluginTemplate: NuxtTemplate = {
const imports: string[] = []
for (const plugin of clientPlugins) {
const path = relative(ctx.nuxt.options.rootDir, plugin.src)
const variable = genSafeVariableName(filename(plugin.src)).replace(PLUGIN_TEMPLATE_RE, '_') + '_' + hash(path)
const variable = genSafeVariableName(filename(plugin.src) || path).replace(PLUGIN_TEMPLATE_RE, '_') + '_' + hash(path)
exports.push(variable)
imports.push(genImport(plugin.src, variable))
}
@ -88,7 +88,7 @@ export const serverPluginTemplate: NuxtTemplate = {
const imports: string[] = []
for (const plugin of serverPlugins) {
const path = relative(ctx.nuxt.options.rootDir, plugin.src)
const variable = genSafeVariableName(filename(path)).replace(PLUGIN_TEMPLATE_RE, '_') + '_' + hash(path)
const variable = genSafeVariableName(filename(plugin.src) || path).replace(PLUGIN_TEMPLATE_RE, '_') + '_' + hash(path)
exports.push(variable)
imports.push(genImport(plugin.src, variable))
}

View File

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import { addBuildPlugin, addTemplate, addTypeTemplate, defineNuxtModule, isIgnored, resolveAlias, tryResolveModule, updateTemplates, useNuxt } from '@nuxt/kit'
import { addBuildPlugin, addTemplate, addTypeTemplate, createIsIgnored, defineNuxtModule, resolveAlias, tryResolveModule, updateTemplates, useNuxt } from '@nuxt/kit'
import { isAbsolute, join, normalize, relative, resolve } from 'pathe'
import type { Import, Unimport } from 'unimport'
import { createUnimport, scanDirExports, toExports } from 'unimport'
@ -118,6 +118,7 @@ export default defineNuxtModule<Partial<ImportsOptions>>({
return IMPORTS_TEMPLATE_RE.test(template.filename)
}
const isIgnored = createIsIgnored(nuxt)
const regenerateImports = async () => {
await ctx.modifyDynamicImports(async (imports) => {
// Clear old imports

View File

@ -530,8 +530,8 @@ export default defineNuxtModule({
getContents: () => 'export { START_LOCATION, useRoute } from \'vue-router\'',
})
nuxt.options.vite.resolve = nuxt.options.vite.resolve || {}
nuxt.options.vite.resolve.dedupe = nuxt.options.vite.resolve.dedupe || []
nuxt.options.vite.resolve ||= {}
nuxt.options.vite.resolve.dedupe ||= []
nuxt.options.vite.resolve.dedupe.push('vue-router')
// Add router options template

View File

@ -287,9 +287,9 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp
handleHotUpdate: {
order: 'post',
handler: ({ file, modules, server }) => {
if (options.isPage?.(file)) {
if (options.routesPath && options.isPage?.(file)) {
const macroModule = server.moduleGraph.getModuleById(file + '?macro=true')
const routesModule = server.moduleGraph.getModuleById('virtual:nuxt:' + options.routesPath)
const routesModule = server.moduleGraph.getModuleById('virtual:nuxt:' + encodeURIComponent(options.routesPath))
return [
...modules,
...macroModule ? [macroModule] : [],

View File

@ -65,7 +65,7 @@ export default defineComponent({
if (import.meta.dev) {
nuxtApp._isNuxtPageUsed = true
}
let pageLoadingEndHookAlreadyCalled = false
return () => {
return h(RouterView, { name: props.name, route: props.route, ...attrs }, {
default: (routeProps: RouterViewSlotProps) => {
@ -99,6 +99,7 @@ export default defineComponent({
const key = generateRouteKey(routeProps, props.pageKey)
if (!nuxtApp.isHydrating && !hasChildrenRoutes(forkRoute, routeProps.route, routeProps.Component) && previousPageKey === key) {
nuxtApp.callHook('page:loading:end')
pageLoadingEndHookAlreadyCalled = true
}
previousPageKey = key
@ -115,7 +116,14 @@ export default defineComponent({
wrapInKeepAlive(keepaliveConfig, h(Suspense, {
suspensible: true,
onPending: () => nuxtApp.callHook('page:start', routeProps.Component),
onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).then(() => nuxtApp.callHook('page:loading:end')).finally(done)) },
onResolve: () => {
nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).then(() => {
if (!pageLoadingEndHookAlreadyCalled) {
return nuxtApp.callHook('page:loading:end')
}
pageLoadingEndHookAlreadyCalled = false
}).finally(done))
},
}, {
default: () => {
const providerVNode = h(RouteProvider, {

View File

@ -68,7 +68,10 @@ export async function resolvePagesRoutes (nuxt = useNuxt()): Promise<NuxtPage[]>
return pages
}
const augmentCtx = { extraExtractionKeys: nuxt.options.experimental.extraPageMetaExtractionKeys }
const augmentCtx = {
extraExtractionKeys: nuxt.options.experimental.extraPageMetaExtractionKeys,
fullyResolvedPaths: new Set(scannedFiles.map(file => file.absolutePath)),
}
if (shouldAugment === 'after-resolve') {
await nuxt.callHook('pages:extend', pages)
await augmentPages(pages, nuxt.vfs, augmentCtx)
@ -121,7 +124,7 @@ export function generateRoutesFromFiles (files: ScannedFile[], options: Generate
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
const tokens = parseSegment(segment!)
const tokens = parseSegment(segment!, file.absolutePath)
// Skip group segments
if (tokens.every(token => token.type === SegmentTokenType.group)) {
@ -154,6 +157,7 @@ export function generateRoutesFromFiles (files: ScannedFile[], options: Generate
}
interface AugmentPagesContext {
fullyResolvedPaths?: Set<string>
pagesToSkip?: Set<string>
augmentedPages?: Set<string>
extraExtractionKeys?: string[]
@ -163,7 +167,9 @@ export async function augmentPages (routes: NuxtPage[], vfs: Record<string, stri
ctx.augmentedPages ??= new Set()
for (const route of routes) {
if (route.file && !ctx.pagesToSkip?.has(route.file)) {
const fileContent = route.file in vfs ? vfs[route.file]! : fs.readFileSync(await resolvePath(route.file), 'utf-8')
const fileContent = route.file in vfs
? vfs[route.file]!
: fs.readFileSync(ctx.fullyResolvedPaths?.has(route.file) ? route.file : await resolvePath(route.file), 'utf-8')
const routeMeta = await getRouteMeta(fileContent, route.file, ctx.extraExtractionKeys)
if (route.meta) {
routeMeta.meta = { ...routeMeta.meta, ...route.meta }
@ -331,7 +337,7 @@ function getRoutePath (tokens: SegmentToken[]): string {
const PARAM_CHAR_RE = /[\w.]/
function parseSegment (segment: string) {
function parseSegment (segment: string, absolutePath: string) {
let state: SegmentParserState = SegmentParserState.initial
let i = 0
@ -418,8 +424,10 @@ function parseSegment (segment: string) {
state = SegmentParserState.initial
} else if (c && PARAM_CHAR_RE.test(c)) {
buffer += c
} else {
// console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`)
} else if (state === SegmentParserState.dynamic || state === SegmentParserState.optional) {
if (c !== '[' && c !== ']') {
logger.warn(`'\`${c}\`' is not allowed in a dynamic route parameter and has been ignored. Consider renaming \`${absolutePath}\`.`)
}
}
break
}

View File

@ -292,6 +292,8 @@ async function getResolvedApp (files: Array<string | { name: string, contents: s
}
for (const plugin of app.plugins) {
plugin.src = normaliseToRepo(plugin.src)!
// @ts-expect-error untyped symbol
delete plugin[Symbol.for('nuxt plugin')]
}
for (const mw of app.middleware) {
mw.path = normaliseToRepo(mw.path)!

View File

@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readPackageJSON } from 'pkg-types'
import { inc } from 'semver'
import { version } from '../package.json'
import { checkDependencyVersion, keyDependencies } from '../src/core/nuxt'
vi.stubGlobal('console', {
...console,
error: vi.fn(console.error),
warn: vi.fn(console.warn),
})
vi.mock('pkg-types', async (og) => {
const originalPkgTypes = (await og<typeof import('pkg-types')>())
return {
...originalPkgTypes,
readPackageJSON: vi.fn(originalPkgTypes.readPackageJSON),
}
})
afterEach(() => {
vi.clearAllMocks()
})
describe('dependency mismatch', () => {
it.sequential('expect mismatched dependency to log a warning', async () => {
vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({
version: '3.0.0',
}))
for (const dep of keyDependencies) {
await checkDependencyVersion(dep, version)
}
// @nuxt/kit is explicitly installed in repo root but @nuxt/schema isn't, so we only
// get warnings about @nuxt/schema
expect(console.warn).toHaveBeenCalledWith(`[nuxt] Expected \`@nuxt/kit\` to be at least \`${version}\` but got \`3.0.0\`. This might lead to unexpected behavior. Check your package.json or refresh your lockfile.`)
vi.mocked(readPackageJSON).mockRestore()
})
it.sequential.each([
{
name: 'nuxt version is lower',
depVersion: inc(version, 'minor'),
},
{
name: 'version matches',
depVersion: version,
},
])('expect no warning when $name.', async ({ depVersion }) => {
vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({
depVersion,
}))
for (const dep of keyDependencies) {
await checkDependencyVersion(dep, version)
}
expect(console.warn).not.toHaveBeenCalled()
vi.mocked(readPackageJSON).mockRestore()
})
})

View File

@ -2,10 +2,7 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { normalize } from 'pathe'
import { withoutTrailingSlash } from 'ufo'
import { readPackageJSON } from 'pkg-types'
import { inc } from 'semver'
import { loadNuxt } from '../src'
import { version } from '../package.json'
const repoRoot = withoutTrailingSlash(normalize(fileURLToPath(new URL('../../../', import.meta.url))))
@ -45,45 +42,3 @@ describe('loadNuxt', () => {
expect(hookRan).toBe(true)
})
})
describe('dependency mismatch', () => {
it('expect mismatched dependency to log a warning', async () => {
vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({
version: '3.0.0',
}))
const nuxt = await loadNuxt({
cwd: repoRoot,
})
// @nuxt/kit is explicitly installed in repo root but @nuxt/schema isn't, so we only
// get warnings about @nuxt/schema
expect(console.warn).toHaveBeenCalledWith(`[nuxt] Expected \`@nuxt/kit\` to be at least \`${version}\` but got \`3.0.0\`. This might lead to unexpected behavior. Check your package.json or refresh your lockfile.`)
vi.mocked(readPackageJSON).mockRestore()
await nuxt.close()
})
it.each([
{
name: 'nuxt version is lower',
depVersion: inc(version, 'minor'),
},
{
name: 'version matches',
depVersion: version,
},
])('expect no warning when $name.', async ({ depVersion }) => {
vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({
depVersion,
}))
const nuxt = await loadNuxt({
cwd: repoRoot,
})
expect(console.warn).not.toHaveBeenCalled()
await nuxt.close()
vi.mocked(readPackageJSON).mockRestore()
})
})

View File

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import type { Nuxt } from '@nuxt/schema'
import { rollup } from 'rollup'
import { VirtualFSPlugin } from '../src/core/plugins/virtual'
describe('virtual fs plugin', () => {
it('should support loading files virtually', async () => {
const code = await generateCode('export { foo } from "#build/foo"', {
vfs: {
'/.nuxt/foo': 'export const foo = "hello world"',
},
})
expect(code).toMatchInlineSnapshot(`
"const foo = "hello world";
export { foo };"
`)
})
it('should support loading virtual files by suffix', async () => {
const code = await generateCode('export { foo } from "#build/foo"', {
mode: 'client',
vfs: {
'/.nuxt/foo.server.ts': 'export const foo = "foo server file"',
'/.nuxt/foo.client.ts': 'export const foo = "foo client file"',
},
})
expect(code).toMatchInlineSnapshot(`
"const foo = "foo client file";
export { foo };"
`)
})
it('should support loading files referenced relatively', async () => {
const code = await generateCode('export { foo } from "#build/foo"', {
vfs: {
'/.nuxt/foo': 'export { foo } from "./bar"',
'/.nuxt/bar': 'export const foo = "relative import"',
},
})
expect(code).toMatchInlineSnapshot(`
"const foo = "relative import";
export { foo };"
`)
})
})
async function generateCode (input: string, options: { mode?: 'client' | 'server', vfs: Record<string, string> }) {
const stubNuxt = {
options: {
extensions: ['.ts', '.js'],
alias: {
'~': '/',
'#build': '/.nuxt',
},
},
vfs: options.vfs,
} as unknown as Nuxt
const bundle = await rollup({
input: 'entry.ts',
plugins: [
{
name: 'entry',
resolveId (id) {
if (id === 'entry.ts') {
return id
}
},
load (id) {
if (id === 'entry.ts') {
return input
}
},
},
VirtualFSPlugin(stubNuxt, { mode: options.mode || 'client', alias: stubNuxt.options.alias }).rollup(),
],
})
const { output: [chunk] } = await bundle.generate({})
return chunk.code.trim()
}

View File

@ -1,4 +1,5 @@
import { defineBuildConfig } from 'unbuild'
import { addRollupTimingsPlugin, stubOptions } from '../../debug/build-config'
import config from '../webpack/build.config'
export default defineBuildConfig({
@ -8,6 +9,12 @@ export default defineBuildConfig({
'#builder',
'@nuxt/schema',
],
stubOptions,
hooks: {
'rollup:options' (ctx, options) {
addRollupTimingsPlugin(options)
},
},
entries: [
{
input: '../webpack/src/index',

View File

@ -43,15 +43,15 @@
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^9.0.2",
"globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"magic-string": "^0.30.17",
"memfs": "^4.17.0",
"ohash": "^1.1.4",
"pathe": "^2.0.1",
"pathe": "^2.0.2",
"pify": "^6.1.0",
"postcss": "^8.5.0",
"postcss": "^8.5.1",
"postcss-import": "^16.1.0",
"postcss-import-resolver": "^2.0.0",
"postcss-loader": "^8.1.1",
@ -75,7 +75,7 @@
"@types/pify": "5.0.4",
"@types/webpack-bundle-analyzer": "4.7.0",
"@types/webpack-hot-middleware": "2.25.9",
"rollup": "4.30.1",
"rollup": "4.31.0",
"unbuild": "3.3.1",
"vue": "3.5.13"
},

View File

@ -1,4 +1,5 @@
import { defineBuildConfig } from 'unbuild'
import { stubOptions } from '../../debug/build-config'
export default defineBuildConfig({
declaration: true,
@ -20,22 +21,16 @@ export default defineBuildConfig({
'src/index',
'src/builder-env',
],
hooks: {
'rollup:options' (ctx, options) {
ctx.options.rollup.dts.respectExternal = false
const isExternal = options.external! as (id: string, importer?: string, isResolved?: boolean) => boolean
options.external = (source, importer, isResolved) => {
if (source === 'untyped' || source === 'knitwork') {
return false
}
return isExternal(source, importer, isResolved)
}
},
stubOptions,
rollup: {
dts: { respectExternal: false },
inlineDependencies: ['untyped', 'knitwork'],
},
externals: [
// Type imports
'@unhead/schema',
'@vitejs/plugin-vue',
'chokidar',
'@vitejs/plugin-vue-jsx',
'@vue/language-core',
'autoprefixer',

View File

@ -44,22 +44,23 @@
"@vue/compiler-sfc": "3.5.13",
"@vue/language-core": "2.2.0",
"c12": "2.0.1",
"chokidar": "4.0.3",
"compatx": "0.1.8",
"esbuild-loader": "4.2.2",
"file-loader": "6.2.0",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"hookable": "5.5.3",
"ignore": "7.0.1",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"ignore": "7.0.3",
"nitro": "npm:nitro-nightly@3.0.0-beta-28938837.19ec5395",
"ofetch": "1.4.1",
"pkg-types": "1.3.0",
"pkg-types": "1.3.1",
"sass-loader": "16.0.4",
"scule": "1.3.0",
"unbuild": "3.3.1",
"unctx": "2.4.1",
"unimport": "3.14.5",
"unimport": "3.14.6",
"untyped": "1.5.2",
"vite": "6.0.7",
"vite": "6.0.9",
"vue": "3.5.13",
"vue-bundle-renderer": "2.1.1",
"vue-loader": "17.4.2",
@ -70,7 +71,7 @@
"dependencies": {
"consola": "^3.4.0",
"defu": "^6.1.4",
"pathe": "^2.0.1",
"pathe": "^2.0.2",
"std-env": "^3.8.0"
},
"engines": {

View File

@ -247,12 +247,16 @@ export default defineUntypedSchema({
*
* Normally, you should not need to set this.
*/
dev: Boolean(isDevelopment),
dev: {
$resolve: val => val ?? Boolean(isDevelopment),
},
/**
* Whether your app is being unit tested.
*/
test: Boolean(isTest),
test: {
$resolve: val => val ?? Boolean(isTest),
},
/**
* Set to `true` to enable debug mode.
@ -519,9 +523,11 @@ export default defineUntypedSchema({
/**
* Options to pass directly to `chokidar`.
* @see [chokidar](https://github.com/paulmillr/chokidar#api)
* @type {typeof import('chokidar').ChokidarOptions}
*/
chokidar: {
ignoreInitial: true,
ignorePermissionErrors: true,
},
},

View File

@ -17,19 +17,19 @@
"prerender": "pnpm build && jiti ./lib/prerender"
},
"devDependencies": {
"@unocss/reset": "65.4.0",
"@unocss/reset": "65.4.2",
"beasties": "0.2.0",
"html-validate": "9.1.3",
"htmlnano": "2.1.1",
"jiti": "2.4.2",
"knitwork": "1.2.0",
"pathe": "2.0.1",
"pathe": "2.0.2",
"prettier": "3.4.2",
"scule": "1.3.0",
"svgo": "3.3.2",
"tinyexec": "0.3.2",
"tinyglobby": "0.2.10",
"unocss": "65.4.0",
"vite": "6.0.7"
"unocss": "65.4.2",
"vite": "6.0.9"
}
}

View File

@ -1,4 +1,5 @@
import { defineBuildConfig } from 'unbuild'
import { addRollupTimingsPlugin, stubOptions } from '../../debug/build-config'
export default defineBuildConfig({
declaration: true,
@ -6,6 +7,12 @@ export default defineBuildConfig({
'src/index',
{ input: 'src/runtime/', outDir: 'dist/runtime', format: 'esm' },
],
stubOptions,
hooks: {
'rollup:options' (ctx, options) {
addRollupTimingsPlugin(options)
},
},
dependencies: [
'vue',
],

View File

@ -26,7 +26,7 @@
},
"devDependencies": {
"@nuxt/schema": "workspace:*",
"rollup": "4.30.1",
"rollup": "4.31.0",
"unbuild": "3.3.1",
"vue": "3.5.13"
},
@ -41,23 +41,22 @@
"defu": "^6.1.4",
"esbuild": "^0.24.2",
"escape-string-regexp": "^5.0.0",
"externality": "^1.0.2",
"get-port-please": "^3.1.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"magic-string": "^0.30.17",
"mlly": "^1.7.4",
"pathe": "^2.0.1",
"pkg-types": "^1.3.0",
"postcss": "^8.5.0",
"pathe": "^2.0.2",
"pkg-types": "^1.3.1",
"postcss": "^8.5.1",
"rollup-plugin-visualizer": "^5.13.1",
"std-env": "^3.8.0",
"ufo": "^1.5.4",
"unenv": "^1.10.0",
"unplugin": "^2.1.2",
"vite": "^6.0.7",
"vite-node": "^2.1.8",
"vite": "^6.0.9",
"vite-node": "^3.0.2",
"vite-plugin-checker": "^0.8.0",
"vue-bundle-renderer": "^2.1.1"
},

View File

@ -110,9 +110,12 @@ export async function buildClient (ctx: ViteBuildContext) {
},
resolve: {
alias: {
// user aliases
...nodeCompat.alias,
...ctx.config.resolve?.alias,
'nitro/runtime': join(ctx.nuxt.options.buildDir, 'nitro.client.mjs'),
// work around vite optimizer bug
'#app-manifest': 'unenv/runtime/mock/empty',
},
dedupe: [
'vue',

View File

@ -1,36 +0,0 @@
import type { ExternalsOptions } from 'externality'
import { ExternalsDefaults, isExternal } from 'externality'
import type { ViteDevServer } from 'vite'
import escapeStringRegexp from 'escape-string-regexp'
import { withTrailingSlash } from 'ufo'
import type { Nuxt } from 'nuxt/schema'
import { resolve } from 'pathe'
import { toArray } from '.'
export function createIsExternal (viteServer: ViteDevServer, nuxt: Nuxt) {
const externalOpts: ExternalsOptions = {
inline: [
/virtual:/,
/\.ts$/,
...ExternalsDefaults.inline || [],
...(
viteServer.config.ssr.noExternal && viteServer.config.ssr.noExternal !== true
? toArray(viteServer.config.ssr.noExternal)
: []
),
],
external: [
'#shared',
new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared)))),
...(viteServer.config.ssr.external as string[]) || [],
/node_modules/,
],
resolve: {
modules: nuxt.options.modulesDir,
type: 'module',
extensions: ['.ts', '.js', '.json', '.vue', '.mjs', '.jsx', '.tsx', '.wasm'],
},
}
return (id: string) => isExternal(id, nuxt.options.rootDir, externalOpts)
}

View File

@ -6,14 +6,12 @@ import { isAbsolute, join, normalize, resolve } from 'pathe'
// import { addDevServerHandler } from '@nuxt/kit'
import { isFileServingAllowed } from 'vite'
import type { ModuleNode, Plugin as VitePlugin } from 'vite'
import { getQuery } from 'ufo'
import { getQuery, withTrailingSlash } from 'ufo'
import { normalizeViteManifest } from 'vue-bundle-renderer'
import { resolve as resolveModule } from 'mlly'
import escapeStringRegexp from 'escape-string-regexp'
import { distDir } from './dirs'
import type { ViteBuildContext } from './vite'
import { isCSS } from './utils'
import { createIsExternal } from './utils/external'
import { transpile } from './utils/transpile'
// TODO: Remove this in favor of registerViteNodeMiddleware
// after Nitropack or h3 allows adding middleware after setup
@ -44,7 +42,7 @@ export function viteNodePlugin (ctx: ViteBuildContext): VitePlugin {
// invalidate changed virtual modules when templates are regenerated
ctx.nuxt.hook('app:templatesGenerated', (_app, changedTemplates) => {
for (const template of changedTemplates) {
const mods = server.moduleGraph.getModulesByFile(`virtual:nuxt:${template.dst}`)
const mods = server.moduleGraph.getModulesByFile(`virtual:nuxt:${encodeURIComponent(template.dst)}`)
for (const mod of mods || []) {
markInvalidate(mod)
@ -118,9 +116,13 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set<string> = ne
const node = new ViteNodeServer(viteServer, {
deps: {
inline: [
/\/node_modules\/(.*\/)?(nuxt|nuxt3|nuxt-nightly)\//,
// Common
/^#/,
...transpile({ isServer: true, isDev: ctx.nuxt.options.dev }),
/\?/,
],
external: [
'#shared',
new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(ctx.nuxt.options.rootDir, ctx.nuxt.options.dir.shared)))),
],
},
transformMode: {
@ -129,15 +131,6 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set<string> = ne
},
})
const isExternal = createIsExternal(viteServer, ctx.nuxt)
node.shouldExternalize = async (id: string) => {
const result = await isExternal(id)
if (result?.external) {
return resolveModule(result.id, { url: ctx.nuxt.options.modulesDir }).catch(() => false)
}
return false
}
return eventHandler(async (event) => {
const moduleId = decodeURI(event.path).substring(1)
if (moduleId === '/') {

View File

@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
import * as vite from 'vite'
import { dirname, join, normalize, resolve } from 'pathe'
import type { Nuxt, NuxtBuilder, ViteConfig } from '@nuxt/schema'
import { addVitePlugin, isIgnored, logger, resolvePath, useNitro } from '@nuxt/kit'
import { addVitePlugin, createIsIgnored, logger, resolvePath, useNitro } from '@nuxt/kit'
import replace from '@rollup/plugin-replace'
import type { RollupReplaceOptions } from '@rollup/plugin-replace'
import { sanitizeFilePath } from 'mlly'
@ -53,6 +53,7 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
const { $client, $server, ...viteConfig } = nuxt.options.vite
const isIgnored = createIsIgnored(nuxt)
const ctx: ViteBuildContext = {
nuxt,
entry,
@ -88,6 +89,7 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
},
},
watch: {
chokidar: { ...nuxt.options.watchers.chokidar, ignored: [isIgnored, /[\\/]node_modules[\\/]/] },
exclude: nuxt.options.ignore,
},
},
@ -101,7 +103,7 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
replace({ preventAssignment: true, ...globalThisReplacements }),
],
server: {
watch: { ignored: isIgnored },
watch: { ...nuxt.options.watchers.chokidar, ignored: [isIgnored, /[\\/]node_modules[\\/]/] },
fs: {
allow: [...new Set(allowDirs)],
},
@ -212,7 +214,7 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
// Invalidate virtual modules when templates are re-generated
ctx.nuxt.hook('app:templatesGenerated', (_app, changedTemplates) => {
for (const template of changedTemplates) {
for (const mod of server.moduleGraph.getModulesByFile(`virtual:nuxt:${template.dst}`) || []) {
for (const mod of server.moduleGraph.getModulesByFile(`virtual:nuxt:${encodeURIComponent(template.dst)}`) || []) {
server.moduleGraph.invalidateModule(mod)
server.reloadModule(mod)
}

View File

@ -1,10 +1,17 @@
import { defineBuildConfig } from 'unbuild'
import { addRollupTimingsPlugin, stubOptions } from '../../debug/build-config'
export default defineBuildConfig({
declaration: true,
entries: [
'src/index',
],
stubOptions,
hooks: {
'rollup:options' (ctx, options) {
addRollupTimingsPlugin(options)
},
},
dependencies: [
'@nuxt/kit',
'unplugin',

View File

@ -42,16 +42,16 @@
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^9.0.2",
"globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"h3": "npm:h3-nightly@1.13.1-20250110-173418-de24917",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"magic-string": "^0.30.17",
"memfs": "^4.17.0",
"mini-css-extract-plugin": "^2.9.2",
"ohash": "^1.1.4",
"pathe": "^2.0.1",
"pathe": "^2.0.2",
"pify": "^6.1.0",
"postcss": "^8.5.0",
"postcss": "^8.5.1",
"postcss-import": "^16.1.0",
"postcss-import-resolver": "^2.0.0",
"postcss-loader": "^8.1.1",
@ -77,7 +77,7 @@
"@types/pify": "5.0.4",
"@types/webpack-bundle-analyzer": "4.7.0",
"@types/webpack-hot-middleware": "2.25.9",
"rollup": "4.30.1",
"rollup": "4.31.0",
"unbuild": "3.3.1",
"vue": "3.5.13"
},

View File

@ -56,7 +56,7 @@ function clientNodeCompat (ctx: WebpackConfigContext) {
}
ctx.config.plugins!.push(new webpack.DefinePlugin({ global: 'globalThis' }))
ctx.config.resolve = ctx.config.resolve || {}
ctx.config.resolve ||= {}
ctx.config.resolve.fallback = {
...env(nodeless).alias,
...ctx.config.resolve.fallback,
@ -92,7 +92,7 @@ function clientHMR (ctx: WebpackConfigContext) {
`webpack-hot-middleware/client?${hotMiddlewareClientOptionsStr}`,
)
ctx.config.plugins = ctx.config.plugins || []
ctx.config.plugins ||= []
ctx.config.plugins.push(new webpack.HotModuleReplacementPlugin())
}

View File

@ -85,7 +85,7 @@ function serverStandalone (ctx: WebpackConfigContext) {
}
function serverPlugins (ctx: WebpackConfigContext) {
ctx.config.plugins = ctx.config.plugins || []
ctx.config.plugins ||= []
// Server polyfills
if (ctx.userConfig.serverURLPolyfill) {

View File

@ -50,7 +50,7 @@ function baseConfig (ctx: WebpackConfigContext) {
}
function basePlugins (ctx: WebpackConfigContext) {
ctx.config.plugins = ctx.config.plugins || []
ctx.config.plugins ||= []
// Add timefix-plugin before other plugins
if (ctx.options.dev) {

View File

@ -28,8 +28,6 @@
"3.x"
],
"ignoreDeps": [
"nitro",
"h3",
"nuxt",
"nuxt3",
"@nuxt/kit"

View File

@ -625,6 +625,44 @@ describe('pages', () => {
const html = await $fetch('/prerender/test')
expect(html).toContain('should be prerendered: true')
})
it('should trigger page:loading:end only once', async () => {
const { page, consoleLogs } = await renderPage('/')
await page.getByText('to page load hook').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/page-load-hook')
const loadingEndLogs = consoleLogs.filter(c => c.text.includes('page:loading:end'))
expect(loadingEndLogs.length).toBe(1)
await page.close()
})
it('should hide nuxt page load indicator after navigate back from nested page', async () => {
const LOAD_INDICATOR_SELECTOR = '.nuxt-loading-indicator'
const { page } = await renderPage('/page-load-hook')
await page.getByText('To sub page').click()
await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/page-load-hook/subpage')
await page.waitForSelector(LOAD_INDICATOR_SELECTOR)
let isVisible = await page.isVisible(LOAD_INDICATOR_SELECTOR)
expect(isVisible).toBe(true)
await page.waitForSelector(LOAD_INDICATOR_SELECTOR, { state: 'hidden' })
isVisible = await page.isVisible(LOAD_INDICATOR_SELECTOR)
expect(isVisible).toBe(false)
await page.goBack()
await page.waitForSelector(LOAD_INDICATOR_SELECTOR)
isVisible = await page.isVisible(LOAD_INDICATOR_SELECTOR)
expect(isVisible).toBe(true)
await page.waitForSelector(LOAD_INDICATOR_SELECTOR, { state: 'hidden' })
isVisible = await page.isVisible(LOAD_INDICATOR_SELECTOR)
expect(isVisible).toBe(false)
await page.close()
})
})
describe('nuxt composables', () => {
@ -2738,7 +2776,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"><!--teleport start anchor--><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')
})
})

6
test/fixtures/basic/app.vue vendored Normal file
View File

@ -0,0 +1,6 @@
<template>
<NuxtLoadingIndicator :throttle="0" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@ -94,6 +94,9 @@
<NuxtLink to="/server-page">
to server page
</NuxtLink>
<NuxtLink to="/page-load-hook">
to page load hook
</NuxtLink>
</div>
</template>

View File

@ -0,0 +1,9 @@
<template>
<div>
Page for hook tests.
<NuxtLink to="/page-load-hook/subpage">
To sub page
</NuxtLink>
<NuxtPage />
</div>
</template>

View File

@ -0,0 +1,7 @@
<template>
<div>
<NuxtLink to="/page-load-hook">
Back to parent
</NuxtLink>
</div>
</template>

View File

@ -0,0 +1,8 @@
export default defineNuxtPlugin((nuxtApp) => {
const route = useRoute()
nuxtApp.hook('page:loading:end', () => {
if (route.path === '/page-load-hook') {
console.log('page:loading:end')
}
})
})

View File

@ -134,7 +134,7 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) {
'type': 'debug',
},
{
'text': `[vite] hot updated: /@id/virtual:nuxt:${fixturePath}/.nuxt/routes.mjs`,
'text': `[vite] hot updated: /@id/virtual:nuxt:${encodeURIComponent(join(fixturePath, '.nuxt/routes.mjs'))}`,
'type': 'debug',
},
])