diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c31ad28b4e..a848c52ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index a5b382aef3..6d2424c52d 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -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 diff --git a/debug/build-config.ts b/debug/build-config.ts new file mode 100644 index 0000000000..254d0d215e --- /dev/null +++ b/debug/build-config.ts @@ -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()) + } +} diff --git a/debug/plugins/timings-babel.mjs b/debug/plugins/timings-babel.mjs new file mode 100644 index 0000000000..75d8b64ab1 --- /dev/null +++ b/debug/plugins/timings-babel.mjs @@ -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 (?:(?[^)]+) \()?(?[^)]+)\)?$/u + const SOURCE_RE = /^(?.+):(?\d+):(?\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))]), + )) + }, + }, + } +}) diff --git a/debug/plugins/timings-unbuild.ts b/debug/plugins/timings-unbuild.ts new file mode 100644 index 0000000000..8916305b4e --- /dev/null +++ b/debug/plugins/timings-unbuild.ts @@ -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 + // eslint-disable-next-line no-var + var ___calls: Record + // eslint-disable-next-line no-var + var ___callers: Record +} + +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 +} diff --git a/docs/1.getting-started/11.testing.md b/docs/1.getting-started/11.testing.md index 60d0b8db18..97bf358b2d 100644 --- a/docs/1.getting-started/11.testing.md +++ b/docs/1.getting-started/11.testing.md @@ -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 `
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 diff --git a/docs/1.getting-started/6.data-fetching.md b/docs/1.getting-started/6.data-fetching.md index f8c48b3759..c82f5ca2f8 100644 --- a/docs/1.getting-started/6.data-fetching.md +++ b/docs/1.getting-started/6.data-fetching.md @@ -202,6 +202,19 @@ const { data: discounts, status } = await useAsyncData('cart-discount', async () ``` +::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 + +``` +:: + ::read-more{to="/docs/api/composables/use-async-data"} Read more about `useAsyncData`. :: diff --git a/docs/2.guide/2.directory-structure/1.content.md b/docs/2.guide/2.directory-structure/1.content.md index 5800a362a0..5b028d49a1 100644 --- a/docs/2.guide/2.directory-structure/1.content.md +++ b/docs/2.guide/2.directory-structure/1.content.md @@ -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 [``](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 [``](https://content.nuxt.com/docs/components/content-renderer) component: ```vue [pages/[...slug\\].vue] + + ``` diff --git a/docs/2.guide/2.directory-structure/2.env.md b/docs/2.guide/2.directory-structure/2.env.md index 422dde9f90..6976671a42 100644 --- a/docs/2.guide/2.directory-structure/2.env.md +++ b/docs/2.guide/2.directory-structure/2.env.md @@ -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`. diff --git a/docs/2.guide/4.recipes/3.custom-usefetch.md b/docs/2.guide/4.recipes/3.custom-usefetch.md index 45d9651237..bf4b08cdbb 100644 --- a/docs/2.guide/4.recipes/3.custom-usefetch.md +++ b/docs/2.guide/4.recipes/3.custom-usefetch.md @@ -116,6 +116,8 @@ export function useAPI( 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. :: diff --git a/docs/3.api/1.components/4.nuxt-link.md b/docs/3.api/1.components/4.nuxt-link.md index d21b798345..afdef5bd74 100644 --- a/docs/3.api/1.components/4.nuxt-link.md +++ b/docs/3.api/1.components/4.nuxt-link.md @@ -16,19 +16,24 @@ links: In this example, we use `` component to link to another page of the application. +::code-group ```vue [pages/index.vue] ``` +```html [(Renders as) index.html] + +About page +``` +:: + ### 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] ``` +```html [(Renders as) index.html] +Post 123 +``` +:: + ::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 `` for `/public` directory files or when pointing to a different app on the same domain, you should use the `external` prop. +By default, `` 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 `` 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. `` will render the link as a standard HTML `` tag. This ensures the link behaves correctly, bypassing Vue Router’s 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] ``` -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] + +``` + +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 `` component to link to a website. ``` -## `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] +``` + +A `noRel` prop can be used to prevent the default `rel` attribute from being added to the absolute links. + +```vue [app.vue] + ``` +::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] +About page not pre-fetched +About page not pre-fetched +``` + +### Custom Prefetch Triggers + +We now support custom prefetch triggers for `` after `v3.13.0`. You can use the `prefetchOn` prop to control when to prefetch links. + +```vue + +``` + +- `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 + +``` + +That you probably don't want both enabled! + +```vue + +``` + +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`, `` 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 `` should wrap its content in an `` 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 `` 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 } } } } diff --git a/docs/3.api/2.composables/use-async-data.md b/docs/3.api/2.composables/use-async-data.md index 1a297d253f..6f422d19e5 100644 --- a/docs/3.api/2.composables/use-async-data.md +++ b/docs/3.api/2.composables/use-async-data.md @@ -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`) diff --git a/docs/3.api/2.composables/use-nuxt-data.md b/docs/3.api/2.composables/use-nuxt-data.md index a88c010715..fc77d5b875 100644 --- a/docs/3.api/2.composables/use-nuxt-data.md +++ b/docs/3.api/2.composables/use-nuxt-data.md @@ -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: { diff --git a/docs/3.api/3.utils/add-route-middleware.md b/docs/3.api/3.utils/add-route-middleware.md index 5db3ab7009..41bdf1a065 100644 --- a/docs/3.api/3.utils/add-route-middleware.md +++ b/docs/3.api/3.utils/add-route-middleware.md @@ -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,16 +59,30 @@ 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: -```ts [plugins/my-plugin.ts] -export default defineNuxtPlugin(() => { - addRouteMiddleware('global-middleware', (to, from) => { - console.log('global middleware that runs on every route change') - }, - { global: true } - ) -}) -``` +- 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(() => { + addRouteMiddleware('global-middleware', (to, from) => { + console.log('global middleware that runs on every route change') + }, + { global: true } + ) + }) + ``` diff --git a/docs/3.api/4.commands/add.md b/docs/3.api/4.commands/add.md index c452ed8b80..458be3b782 100644 --- a/docs/3.api/4.commands/add.md +++ b/docs/3.api/4.commands/add.md @@ -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 --- diff --git a/docs/3.api/4.commands/analyze.md b/docs/3.api/4.commands/analyze.md index 60372bc7c0..f3bb3c3ea6 100644 --- a/docs/3.api/4.commands/analyze.md +++ b/docs/3.api/4.commands/analyze.md @@ -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 --- diff --git a/docs/3.api/4.commands/build.md b/docs/3.api/4.commands/build.md index 2fdad2f675..80a3ccdabe 100644 --- a/docs/3.api/4.commands/build.md +++ b/docs/3.api/4.commands/build.md @@ -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 --- diff --git a/docs/3.api/4.commands/cleanup.md b/docs/3.api/4.commands/cleanup.md index b7052cda08..97ffeaa1fa 100644 --- a/docs/3.api/4.commands/cleanup.md +++ b/docs/3.api/4.commands/cleanup.md @@ -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 --- diff --git a/docs/3.api/4.commands/dev.md b/docs/3.api/4.commands/dev.md index 44daff53ed..d94de70904 100644 --- a/docs/3.api/4.commands/dev.md +++ b/docs/3.api/4.commands/dev.md @@ -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 --- diff --git a/docs/3.api/4.commands/devtools.md b/docs/3.api/4.commands/devtools.md index 8b65a3c421..ab3d6921f9 100644 --- a/docs/3.api/4.commands/devtools.md +++ b/docs/3.api/4.commands/devtools.md @@ -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 --- diff --git a/docs/3.api/4.commands/generate.md b/docs/3.api/4.commands/generate.md index d0b621af58..6e152bb8ff 100644 --- a/docs/3.api/4.commands/generate.md +++ b/docs/3.api/4.commands/generate.md @@ -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 --- diff --git a/docs/3.api/4.commands/info.md b/docs/3.api/4.commands/info.md index c47593497d..79f3eb0aac 100644 --- a/docs/3.api/4.commands/info.md +++ b/docs/3.api/4.commands/info.md @@ -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 --- diff --git a/docs/3.api/4.commands/init.md b/docs/3.api/4.commands/init.md index 4e6eeda079..168671ab4e 100644 --- a/docs/3.api/4.commands/init.md +++ b/docs/3.api/4.commands/init.md @@ -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 --- diff --git a/docs/3.api/4.commands/module.md b/docs/3.api/4.commands/module.md index b54bcbe61a..846f15272d 100644 --- a/docs/3.api/4.commands/module.md +++ b/docs/3.api/4.commands/module.md @@ -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 --- diff --git a/docs/3.api/4.commands/prepare.md b/docs/3.api/4.commands/prepare.md index da5a302a5a..71186d9cbb 100644 --- a/docs/3.api/4.commands/prepare.md +++ b/docs/3.api/4.commands/prepare.md @@ -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 --- diff --git a/docs/3.api/4.commands/preview.md b/docs/3.api/4.commands/preview.md index fd3c2325af..2548d8b7a5 100644 --- a/docs/3.api/4.commands/preview.md +++ b/docs/3.api/4.commands/preview.md @@ -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 --- diff --git a/docs/3.api/4.commands/typecheck.md b/docs/3.api/4.commands/typecheck.md index 40d1512f32..85276f2489 100644 --- a/docs/3.api/4.commands/typecheck.md +++ b/docs/3.api/4.commands/typecheck.md @@ -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 --- diff --git a/docs/3.api/4.commands/upgrade.md b/docs/3.api/4.commands/upgrade.md index fe64fc743c..88144d0918 100644 --- a/docs/3.api/4.commands/upgrade.md +++ b/docs/3.api/4.commands/upgrade.md @@ -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 --- diff --git a/package.json b/package.json index c8c6acdd9d..43f4099319 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kit/build.config.ts b/packages/kit/build.config.ts index d2a1f7fa7d..b0a2cf2487 100644 --- a/packages/kit/build.config.ts +++ b/packages/kit/build.config.ts @@ -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', diff --git a/packages/kit/package.json b/packages/kit/package.json index 71c6917b9a..546b8254db 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -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": { diff --git a/packages/kit/src/build.ts b/packages/kit/src/build.ts index ba4a1d17ba..0f3bb8f48c 100644 --- a/packages/kit/src/build.ts +++ b/packages/kit/src/build.ts @@ -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) } diff --git a/packages/kit/src/components.ts b/packages/kit/src/components.ts index 82394e3811..b10fd98f16 100644 --- a/packages/kit/src/components.ts +++ b/packages/kit/src/components.ts @@ -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=2.13' }, nuxt) - nuxt.options.components = nuxt.options.components || [] + nuxt.options.components ||= [] if (!opts.mode) { const [, mode = 'all'] = opts.filePath.match(MODE_RE) || [] diff --git a/packages/kit/src/ignore.ts b/packages/kit/src/ignore.ts index dc18508a06..8e52e59b94 100644 --- a/packages/kit/src/ignore.ts +++ b/packages/kit/src/ignore.ts @@ -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 diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 04056b2784..f14fd8c81d 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -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' diff --git a/packages/kit/src/loader/config.ts b/packages/kit/src/loader/config.ts index 80139fb015..74652c9b43 100644 --- a/packages/kit/src/loader/config.ts +++ b/packages/kit/src/loader/config.ts @@ -58,7 +58,7 @@ export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise() 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 diff --git a/packages/kit/src/loader/nuxt.ts b/packages/kit/src/loader/nuxt.ts index 892286f5b6..71e60a908e 100644 --- a/packages/kit/src/loader/nuxt.ts +++ b/packages/kit/src/loader/nuxt.ts @@ -16,7 +16,7 @@ export interface LoadNuxtOptions extends LoadNuxtConfigOptions { export async function loadNuxt (opts: LoadNuxtOptions): Promise { // 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 diff --git a/packages/kit/src/module/define.ts b/packages/kit/src/module/define.ts index b0f30d80fb..4624692527 100644 --- a/packages/kit/src/module/define.ts +++ b/packages/kit/src/module/define.ts @@ -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 } diff --git a/packages/kit/src/module/install.ts b/packages/kit/src/module/install.ts index 5164f512f4..ba16c59496 100644 --- a/packages/kit/src/module/install.ts +++ b/packages/kit/src/module/install.ts @@ -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() + 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) { diff --git a/packages/kit/src/nitro.ts b/packages/kit/src/nitro.ts index 4c046f6a9d..361c3c7872 100644 --- a/packages/kit/src/nitro.ts +++ b/packages/kit/src/nitro.ts @@ -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) diff --git a/packages/kit/src/pages.ts b/packages/kit/src/pages.ts index 35c73fae83..dbb153487e 100644 --- a/packages/kit/src/pages.ts +++ b/packages/kit/src/pages.ts @@ -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) diff --git a/packages/kit/src/plugin.ts b/packages/kit/src/plugin.ts index 116721214f..26ae9b70dc 100644 --- a/packages/kit/src/plugin.ts +++ b/packages/kit/src/plugin.ts @@ -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) diff --git a/packages/kit/src/template.ts b/packages/kit/src/template.ts index 8fa065fad7..9bb78f0f28 100644 --- a/packages/kit/src/template.ts +++ b/packages/kit/src/template.ts @@ -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 (_template: NuxtTemplate | 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))) { diff --git a/packages/kit/src/utils.ts b/packages/kit/src/utils.ts index 89fa591c50..0816bcf5cd 100644 --- a/packages/kit/src/utils.ts +++ b/packages/kit/src/utils.ts @@ -3,4 +3,19 @@ export function toArray (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 (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+)*$/ diff --git a/packages/nuxt/build.config.ts b/packages/nuxt/build.config.ts index 4301b165fb..233f249572 100644 --- a/packages/nuxt/build.config.ts +++ b/packages/nuxt/build.config.ts @@ -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', diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 749ba0a2a5..ee8ce5ec03 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -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", diff --git a/packages/nuxt/src/app/components/client-only.ts b/packages/nuxt/src/app/components/client-only.ts index c65eec1b55..b3c9aeb3a1 100644 --- a/packages/nuxt/src/app/components/client-only.ts +++ b/packages/nuxt/src/app/components/client-only.ts @@ -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 `` 🤷‍♂️ @@ -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 (component: T) { if (isPromise(setupState)) { return Promise.resolve(setupState).then((setupState) => { if (typeof setupState !== 'function') { - setupState = setupState || {} + setupState ||= {} setupState.mounted$ = mounted$ return setupState } diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 353f8ccda6..c1bbd619cf 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -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,12 +398,14 @@ 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 (shouldPrefetch('interaction')) { - routerLinkProps.onPointerenter = prefetch.bind(null, undefined) - routerLinkProps.onFocus = prefetch.bind(null, undefined) - } - if (prefetched.value) { - routerLinkProps.class = props.prefetchedClass || options.prefetchedClass + if (import.meta.client) { + if (shouldPrefetch('interaction')) { + routerLinkProps.onPointerenter = prefetch.bind(null, undefined) + routerLinkProps.onFocus = prefetch.bind(null, undefined) + } + if (prefetched.value) { + routerLinkProps.class = props.prefetchedClass || options.prefetchedClass + } } routerLinkProps.rel = props.rel || undefined } diff --git a/packages/nuxt/src/app/composables/once.ts b/packages/nuxt/src/app/composables/once.ts index ae8746fff0..7bd3a98c23 100644 --- a/packages/nuxt/src/app/composables/once.ts +++ b/packages/nuxt/src/app/composables/once.ts @@ -37,7 +37,7 @@ export async function callOnce (...args: any): Promise { return } - nuxtApp._once = nuxtApp._once || {} + nuxtApp._once ||= {} nuxtApp._once[_key] = nuxtApp._once[_key] || fn() || true await nuxtApp._once[_key] nuxtApp.payload.once.add(_key) diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index f61061e5d9..97d318c12c 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -250,7 +250,7 @@ export default defineNuxtModule({ // 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({ 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 = {}') diff --git a/packages/nuxt/src/components/plugins/transform.ts b/packages/nuxt/src/components/plugins/transform.ts index ba819bbfa3..f7bf3586c6 100644 --- a/packages/nuxt/src/components/plugins/transform.ts +++ b/packages/nuxt/src/components/plugins/transform.ts @@ -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 diff --git a/packages/nuxt/src/core/app.ts b/packages/nuxt/src/core/app.ts index 887604d5e3..75248d9bfb 100644 --- a/packages/nuxt/src/core/app.ts +++ b/packages/nuxt/src/core/app.ts @@ -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() const changedTemplates: Array> = [] 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) diff --git a/packages/nuxt/src/core/builder.ts b/packages/nuxt/src/core/builder.ts index 6b24372b88..84e2c8e317 100644 --- a/packages/nuxt/src/core/builder.ts +++ b/packages/nuxt/src/core/builder.ts @@ -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) { - checkForExternalConfigurationFiles() + 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 = {} watcher.on('all', (event, path) => { diff --git a/packages/nuxt/src/core/cache.ts b/packages/nuxt/src/core/cache.ts index 3096ecf554..5c24dab590 100644 --- a/packages/nuxt/src/core/cache.ts +++ b/packages/nuxt/src/core/cache.ts @@ -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 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, diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 698cb04b3a..085a2d8f80 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -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') }) }) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 957da98314..e3d7f1bdfb 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -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 { const nuxt = createNuxt(options) - for (const dep of keyDependencies) { - checkDependencyVersion(dep, nuxt._version) + 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 { return nuxt } -async function checkDependencyVersion (name: string, nuxtVersion: string): Promise { +export async function checkDependencyVersion (name: string, nuxtVersion: string): Promise { const path = await resolvePath(name, { fallbackToOriginal: true }).catch(() => null) if (!path || path === name) { return } diff --git a/packages/nuxt/src/core/plugins/virtual.ts b/packages/nuxt/src/core/plugins/virtual.ts index e50970c225..2a3f00a3bd 100644 --- a/packages/nuxt/src/core/plugins/virtual.ts +++ b/packages/nuxt/src/core/plugins/virtual.ts @@ -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, } }, diff --git a/packages/nuxt/src/core/runtime/nitro/no-ssr.ts b/packages/nuxt/src/core/runtime/nitro/no-ssr.ts index 556c3f98e5..d6ede3076d 100644 --- a/packages/nuxt/src/core/runtime/nitro/no-ssr.ts +++ b/packages/nuxt/src/core/runtime/nitro/no-ssr.ts @@ -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 } }) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index fe1bfee5f4..4c817499fc 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -486,8 +486,8 @@ export default defineRenderHandler(async (event): Promise - 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) diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index aef60133d0..7a6890bf93 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -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)) } diff --git a/packages/nuxt/src/imports/module.ts b/packages/nuxt/src/imports/module.ts index 4854dc8677..1d669b2eaa 100644 --- a/packages/nuxt/src/imports/module.ts +++ b/packages/nuxt/src/imports/module.ts @@ -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>({ return IMPORTS_TEMPLATE_RE.test(template.filename) } + const isIgnored = createIsIgnored(nuxt) const regenerateImports = async () => { await ctx.modifyDynamicImports(async (imports) => { // Clear old imports diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index cfb390f1d6..8238f27873 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -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 diff --git a/packages/nuxt/src/pages/plugins/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts index bde07df6a3..0a6cd606bc 100644 --- a/packages/nuxt/src/pages/plugins/page-meta.ts +++ b/packages/nuxt/src/pages/plugins/page-meta.ts @@ -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] : [], diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts index 5ba304cd23..3f82ed8393 100644 --- a/packages/nuxt/src/pages/runtime/page.ts +++ b/packages/nuxt/src/pages/runtime/page.ts @@ -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, { diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 7f078eed28..5d8c6777ed 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -68,7 +68,10 @@ export async function resolvePagesRoutes (nuxt = useNuxt()): Promise 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 pagesToSkip?: Set augmentedPages?: Set extraExtractionKeys?: string[] @@ -163,7 +167,9 @@ export async function augmentPages (routes: NuxtPage[], vfs: Record { + const originalPkgTypes = (await og()) + 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() + }) +}) diff --git a/packages/nuxt/test/load-nuxt.test.ts b/packages/nuxt/test/load-nuxt.test.ts index dffcdf7130..96fd8723cf 100644 --- a/packages/nuxt/test/load-nuxt.test.ts +++ b/packages/nuxt/test/load-nuxt.test.ts @@ -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() - }) -}) diff --git a/packages/nuxt/test/virtual.test.ts b/packages/nuxt/test/virtual.test.ts new file mode 100644 index 0000000000..edd386ff9d --- /dev/null +++ b/packages/nuxt/test/virtual.test.ts @@ -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 }) { + 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() +} diff --git a/packages/rspack/build.config.ts b/packages/rspack/build.config.ts index c11f11a10c..16742a8dae 100644 --- a/packages/rspack/build.config.ts +++ b/packages/rspack/build.config.ts @@ -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', diff --git a/packages/rspack/package.json b/packages/rspack/package.json index 86f2c7e178..9f29935b14 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -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" }, diff --git a/packages/schema/build.config.ts b/packages/schema/build.config.ts index f4f1d79bfb..c78328916c 100644 --- a/packages/schema/build.config.ts +++ b/packages/schema/build.config.ts @@ -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', diff --git a/packages/schema/package.json b/packages/schema/package.json index f05907af11..c2e26bd8fe 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -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": { diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index 59848e08dc..1d306f1630 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -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, }, }, diff --git a/packages/ui-templates/package.json b/packages/ui-templates/package.json index 1f38b337b8..08ad76f59a 100644 --- a/packages/ui-templates/package.json +++ b/packages/ui-templates/package.json @@ -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" } } diff --git a/packages/vite/build.config.ts b/packages/vite/build.config.ts index b696460b7f..3c3789865e 100644 --- a/packages/vite/build.config.ts +++ b/packages/vite/build.config.ts @@ -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', ], diff --git a/packages/vite/package.json b/packages/vite/package.json index 43101ebdb7..38dcb4ce0a 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -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" }, diff --git a/packages/vite/src/client.ts b/packages/vite/src/client.ts index ef4204ba40..0bf79e871c 100644 --- a/packages/vite/src/client.ts +++ b/packages/vite/src/client.ts @@ -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', diff --git a/packages/vite/src/utils/external.ts b/packages/vite/src/utils/external.ts deleted file mode 100644 index c6889cb3a1..0000000000 --- a/packages/vite/src/utils/external.ts +++ /dev/null @@ -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) -} diff --git a/packages/vite/src/vite-node.ts b/packages/vite/src/vite-node.ts index 2f2c054cda..ee29478ea1 100644 --- a/packages/vite/src/vite-node.ts +++ b/packages/vite/src/vite-node.ts @@ -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 = 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 = 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 === '/') { diff --git a/packages/vite/src/vite.ts b/packages/vite/src/vite.ts index 6d86f0ba9e..7471dbdfd8 100644 --- a/packages/vite/src/vite.ts +++ b/packages/vite/src/vite.ts @@ -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) } diff --git a/packages/webpack/build.config.ts b/packages/webpack/build.config.ts index e1567f1bb6..03431c3e1a 100644 --- a/packages/webpack/build.config.ts +++ b/packages/webpack/build.config.ts @@ -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', diff --git a/packages/webpack/package.json b/packages/webpack/package.json index 7ae73d654e..6f87959e01 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -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" }, diff --git a/packages/webpack/src/configs/client.ts b/packages/webpack/src/configs/client.ts index f147745939..da08af89a2 100644 --- a/packages/webpack/src/configs/client.ts +++ b/packages/webpack/src/configs/client.ts @@ -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()) } diff --git a/packages/webpack/src/configs/server.ts b/packages/webpack/src/configs/server.ts index 574b930e7f..64347f78a7 100644 --- a/packages/webpack/src/configs/server.ts +++ b/packages/webpack/src/configs/server.ts @@ -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) { diff --git a/packages/webpack/src/presets/base.ts b/packages/webpack/src/presets/base.ts index 4e80ea8c54..bafceb8291 100644 --- a/packages/webpack/src/presets/base.ts +++ b/packages/webpack/src/presets/base.ts @@ -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) { diff --git a/renovate.json b/renovate.json index 416d84ce49..3d3d14ecd2 100644 --- a/renovate.json +++ b/renovate.json @@ -28,8 +28,6 @@ "3.x" ], "ignoreDeps": [ - "nitro", - "h3", "nuxt", "nuxt3", "@nuxt/kit" diff --git a/test/basic.test.ts b/test/basic.test.ts index 50a36b2a27..1253413a1e 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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('/nuxt-teleport') // Teleport is appended to body, after the __nuxt div - expect(html).toContain('

Normal content

Nuxt Teleport

Normal content

Nuxt Teleport
+ + + + + diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index d0fcd1513c..84edf9d105 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -94,6 +94,9 @@ to server page + + to page load hook + diff --git a/test/fixtures/basic/pages/page-load-hook.vue b/test/fixtures/basic/pages/page-load-hook.vue new file mode 100644 index 0000000000..867cf5f097 --- /dev/null +++ b/test/fixtures/basic/pages/page-load-hook.vue @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/basic/pages/page-load-hook/[slug].vue b/test/fixtures/basic/pages/page-load-hook/[slug].vue new file mode 100644 index 0000000000..6bb96bf43e --- /dev/null +++ b/test/fixtures/basic/pages/page-load-hook/[slug].vue @@ -0,0 +1,7 @@ + diff --git a/test/fixtures/basic/plugins/page-hook-plugin.ts b/test/fixtures/basic/plugins/page-hook-plugin.ts new file mode 100644 index 0000000000..880f7f1ac4 --- /dev/null +++ b/test/fixtures/basic/plugins/page-hook-plugin.ts @@ -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') + } + }) +}) diff --git a/test/hmr.test.ts b/test/hmr.test.ts index 7c6ea46705..c0ecfcb497 100644 --- a/test/hmr.test.ts +++ b/test/hmr.test.ts @@ -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', }, ])