From 194ff339754480a73f67cd88978c0c8c4aa8ee97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0=20/=20green?= Date: Fri, 25 Oct 2024 17:54:35 +0900 Subject: [PATCH 01/23] test: add compat code for vite v6 (#29677) --- test/basic.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/basic.test.ts b/test/basic.test.ts index 1c65baa6bb..d4039d31cd 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -2374,7 +2374,7 @@ describe('component islands', () => { "link": [], "style": [ { - "innerHTML": "pre[data-v-xxxxx]{color:blue}", + "innerHTML": "pre[data-v-xxxxx]{color:#00f}", }, ], } @@ -2743,7 +2743,11 @@ function normaliseIslandResult (result: NuxtIslandResponse) { for (const style of result.head.style) { if (typeof style !== 'string') { if (style.innerHTML) { - style.innerHTML = (style.innerHTML as string).replace(/data-v-[a-z0-9]+/g, 'data-v-xxxxx') + style.innerHTML = + (style.innerHTML as string) + .replace(/data-v-[a-z0-9]+/g, 'data-v-xxxxx') + // Vite 6 enables CSS minify by default for SSR + .replace(/blue/, '#00f') } if (style.key) { style.key = style.key.replace(/-[a-z0-9]+$/i, '') From a88dcff2aa5c7207a1c802a6f6b1f9e23da65b27 Mon Sep 17 00:00:00 2001 From: xjccc <546534045@qq.com> Date: Fri, 25 Oct 2024 16:55:05 +0800 Subject: [PATCH 02/23] docs: update lifecycle hooks (#29678) --- docs/3.api/6.advanced/1.hooks.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/3.api/6.advanced/1.hooks.md b/docs/3.api/6.advanced/1.hooks.md index 894f104728..3c5381308a 100644 --- a/docs/3.api/6.advanced/1.hooks.md +++ b/docs/3.api/6.advanced/1.hooks.md @@ -52,7 +52,8 @@ Hook | Arguments | Description `build:manifest` | `manifest` | Called during the manifest build by Vite and webpack. This allows customizing the manifest that Nitro will use to render ` +``` + +::caution +Be very careful before proxying headers to an external API and just include headers that you need. Not all headers are safe to be bypassed and might introduce unwanted behavior. Here is a list of common headers that are NOT to be proxied: + +- `host`, `accept` +- `content-length`, `content-md5`, `content-type` +- `x-forwarded-host`, `x-forwarded-port`, `x-forwarded-proto` +- `cf-connecting-ip`, `cf-ray` +:: + +::tip +You can also use [`useRequestFetch`](/docs/api/composables/use-request-fetch) to proxy headers to the call automatically. +::: + ## `useFetch` The [`useFetch`](/docs/api/composables/use-fetch) composable uses `$fetch` under-the-hood to make SSR-safe network calls in the setup function. @@ -117,8 +148,8 @@ Watch the video from Alexander Lichter to avoid using `useFetch` the wrong way! The `useAsyncData` composable is responsible for wrapping async logic and returning the result once it is resolved. ::tip -`useFetch(url)` is nearly equivalent to `useAsyncData(url, () => $fetch(url))`. :br -It's developer experience sugar for the most common use case. +`useFetch(url)` is nearly equivalent to `useAsyncData(url, () => event.$fetch(url))`. :br +It's developer experience sugar for the most common use case. (You can find out more about `event.fetch` at [`useRequestFetch`](/docs/api/composables/use-request-fetch).) :: ::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=0X-aOpSGabA" target="_blank"} @@ -458,32 +489,13 @@ For finer control, the `status` variable can be: - `error` when the fetch fails - `success` when the fetch is completed successfully -## Passing Headers and cookies +## Passing Headers and Cookies -When we call `$fetch` in the browser, user headers like `cookie` will be directly sent to the API. But during server-side-rendering, since the `$fetch` request takes place 'internally' within the server, it doesn't include the user's browser cookies, nor does it pass on cookies from the fetch response. +When we call `$fetch` in the browser, user headers like `cookie` will be directly sent to the API. -### Pass Client Headers to the API +Normally, during server-side-rendering, since the `$fetch` request takes place 'internally' within the server, it wouldn't include the user's browser cookies, nor pass on cookies from the fetch response. -We can use [`useRequestHeaders`](/docs/api/composables/use-request-headers) to access and proxy cookies to the API from server-side. - -The example below adds the request headers to an isomorphic `$fetch` call to ensure that the API endpoint has access to the same `cookie` header originally sent by the user. - -```vue - -``` - -::caution -Be very careful before proxying headers to an external API and just include headers that you need. Not all headers are safe to be bypassed and might introduce unwanted behavior. Here is a list of common headers that are NOT to be proxied: - -- `host`, `accept` -- `content-length`, `content-md5`, `content-type` -- `x-forwarded-host`, `x-forwarded-port`, `x-forwarded-proto` -- `cf-connecting-ip`, `cf-ray` -:: +However, when calling `useFetch` on the server, Nuxt will use [`useRequestFetch`](/docs/api/composables/use-request-fetch) to proxy headers and cookies (with the exception of headers not meant to be forwarded, like `host`). ### Pass Cookies From Server-side API Calls on SSR Response From be892629ebf2661851e508e4b7c76db85fef5e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Est=C3=A9ban?= Date: Sat, 2 Nov 2024 22:13:41 +0100 Subject: [PATCH 18/23] feat(schema,nuxt): add `shared/` folder and `#shared` alias (#28682) --- packages/nuxt/src/core/nitro.ts | 23 +++++++++++++--- packages/nuxt/src/core/nuxt.ts | 27 +++++++++++++------ .../src/core/plugins/import-protection.ts | 27 +++++++++++++------ packages/nuxt/src/imports/module.ts | 8 ++++++ packages/nuxt/test/import-protection.test.ts | 10 +++---- packages/schema/src/config/common.ts | 8 +++++- packages/vite/src/server.ts | 9 ++++++- packages/vite/src/utils/external.ts | 12 ++++++--- packages/vite/src/vite-node.ts | 2 +- packages/webpack/src/configs/server.ts | 8 ++++-- 10 files changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index d00d4639e6..d48730384d 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -17,7 +17,7 @@ import { version as nuxtVersion } from '../../package.json' import { distDir } from '../dirs' import { toArray } from '../utils' import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon' -import { nuxtImportProtections } from './plugins/import-protection' +import { createImportProtectionPatterns } from './plugins/import-protection' import { EXTENSION_RE } from './utils' const logLevelMapReverse = { @@ -49,6 +49,8 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { .map(m => m.entryPath!), ) + const isNuxtV4 = nuxt.options.future?.compatibilityVersion === 4 + const nitroConfig: NitroConfig = defu(nuxt.options.nitro, { debug: nuxt.options.debug, rootDir: nuxt.options.rootDir, @@ -66,6 +68,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { }, imports: { autoImport: nuxt.options.imports.autoImport as boolean, + dirs: isNuxtV4 + ? [ + resolve(nuxt.options.rootDir, 'shared', 'utils'), + resolve(nuxt.options.rootDir, 'shared', 'types'), + ] + : [], imports: [ { as: '__buildAssetsURL', @@ -362,11 +370,20 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { // Register nuxt protection patterns nitroConfig.rollupConfig!.plugins = await nitroConfig.rollupConfig!.plugins || [] nitroConfig.rollupConfig!.plugins = toArray(nitroConfig.rollupConfig!.plugins) + + const sharedDir = withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared)) + const relativeSharedDir = withTrailingSlash(relative(nuxt.options.rootDir, resolve(nuxt.options.rootDir, nuxt.options.dir.shared))) + const sharedPatterns = [/^#shared\//, new RegExp('^' + escapeRE(sharedDir)), new RegExp('^' + escapeRE(relativeSharedDir))] nitroConfig.rollupConfig!.plugins!.push( ImpoundPlugin.rollup({ cwd: nuxt.options.rootDir, - patterns: nuxtImportProtections(nuxt, { isNitro: true }), - exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/], + include: sharedPatterns, + patterns: createImportProtectionPatterns(nuxt, { context: 'shared' }), + }), + ImpoundPlugin.rollup({ + cwd: nuxt.options.rootDir, + patterns: createImportProtectionPatterns(nuxt, { context: 'nitro-app' }), + exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/, ...sharedPatterns], }), ) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index a4c7887817..ae8e2f9c66 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -18,7 +18,6 @@ import type { DateString } from 'compatx' import escapeRE from 'escape-string-regexp' import { withTrailingSlash, withoutLeadingSlash } from 'ufo' import { ImpoundPlugin } from 'impound' -import type { ImpoundOptions } from 'impound' import defu from 'defu' import { gt, satisfies } from 'semver' import { hasTTY, isCI } from 'std-env' @@ -32,7 +31,7 @@ import { distDir, pkgDir } from '../dirs' import { version } from '../../package.json' import { scriptsStubsPreset } from '../imports/presets' import { resolveTypePath } from './utils/types' -import { nuxtImportProtections } from './plugins/import-protection' +import { createImportProtectionPatterns } from './plugins/import-protection' import { UnctxTransformPlugin } from './plugins/unctx' import { TreeShakeComposablesPlugin } from './plugins/tree-shake' import { DevOnlyPlugin } from './plugins/dev-only' @@ -249,16 +248,28 @@ async function initNuxt (nuxt: Nuxt) { // Add plugin normalization plugin addBuildPlugin(RemovePluginMetadataPlugin(nuxt)) + // shared folder import protection + const sharedDir = withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared)) + const relativeSharedDir = withTrailingSlash(relative(nuxt.options.rootDir, resolve(nuxt.options.rootDir, nuxt.options.dir.shared))) + const sharedPatterns = [/^#shared\//, new RegExp('^' + escapeRE(sharedDir)), new RegExp('^' + escapeRE(relativeSharedDir))] + const sharedProtectionConfig = { + cwd: nuxt.options.rootDir, + include: sharedPatterns, + patterns: createImportProtectionPatterns(nuxt, { context: 'shared' }), + } + addVitePlugin(() => ImpoundPlugin.vite(sharedProtectionConfig), { server: false }) + addWebpackPlugin(() => ImpoundPlugin.webpack(sharedProtectionConfig), { server: false }) + // Add import protection - const config: ImpoundOptions = { + const nuxtProtectionConfig = { cwd: nuxt.options.rootDir, // Exclude top-level resolutions by plugins - exclude: [join(nuxt.options.srcDir, 'index.html')], - patterns: nuxtImportProtections(nuxt), + exclude: [relative(nuxt.options.rootDir, join(nuxt.options.srcDir, 'index.html')), ...sharedPatterns], + patterns: createImportProtectionPatterns(nuxt, { context: 'nuxt-app' }), } - addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...config, error: false }), { name: 'nuxt:import-protection' }), { client: false }) - addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...config, error: true }), { name: 'nuxt:import-protection' }), { server: false }) - addWebpackPlugin(() => ImpoundPlugin.webpack(config)) + addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...nuxtProtectionConfig, error: false }), { name: 'nuxt:import-protection' }), { client: false }) + addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...nuxtProtectionConfig, error: true }), { name: 'nuxt:import-protection' }), { server: false }) + addWebpackPlugin(() => ImpoundPlugin.webpack(nuxtProtectionConfig)) // add resolver for modules used in virtual files addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { client: false }) diff --git a/packages/nuxt/src/core/plugins/import-protection.ts b/packages/nuxt/src/core/plugins/import-protection.ts index 0e6e7fa999..497d7d523d 100644 --- a/packages/nuxt/src/core/plugins/import-protection.ts +++ b/packages/nuxt/src/core/plugins/import-protection.ts @@ -9,12 +9,17 @@ interface ImportProtectionOptions { exclude?: Array } -export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { isNitro?: boolean } = {}) => { +interface NuxtImportProtectionOptions { + context: 'nuxt-app' | 'nitro-app' | 'shared' +} + +export const createImportProtectionPatterns = (nuxt: { options: NuxtOptions }, options: NuxtImportProtectionOptions) => { const patterns: ImportProtectionOptions['patterns'] = [] + const context = contextFlags[options.context] patterns.push([ /^(nuxt|nuxt3|nuxt-nightly)$/, - '`nuxt`, `nuxt3` or `nuxt-nightly` cannot be imported directly.' + (options.isNitro ? '' : ' Instead, import runtime Nuxt composables from `#app` or `#imports`.'), + `\`nuxt\`, or \`nuxt-nightly\` cannot be imported directly in ${context}.` + (options.context === 'nuxt-app' ? ' Instead, import runtime Nuxt composables from `#app` or `#imports`.' : ''), ]) patterns.push([ @@ -26,27 +31,33 @@ export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { for (const mod of nuxt.options.modules.filter(m => typeof m === 'string')) { patterns.push([ - new RegExp(`^${escapeRE(mod as string)}$`), + new RegExp(`^${escapeRE(mod)}$`), 'Importing directly from module entry-points is not allowed.', ]) } for (const i of [/(^|node_modules\/)@nuxt\/(kit|test-utils)/, /(^|node_modules\/)nuxi/, /(^|node_modules\/)nitro(?:pack)?(?:-nightly)?(?:$|\/)(?!(?:dist\/)?runtime|types)/, /(^|node_modules\/)nuxt\/(config|kit|schema)/]) { - patterns.push([i, 'This module cannot be imported' + (options.isNitro ? ' in server runtime.' : ' in the Vue part of your app.')]) + patterns.push([i, `This module cannot be imported in ${context}.`]) } - if (options.isNitro) { + if (options.context === 'nitro-app' || options.context === 'shared') { for (const i of ['#app', /^#build(\/|$)/]) { - patterns.push([i, 'Vue app aliases are not allowed in server runtime.']) + patterns.push([i, `Vue app aliases are not allowed in ${context}.`]) } } - if (!options.isNitro) { + if (options.context === 'nuxt-app' || options.context === 'shared') { patterns.push([ new RegExp(escapeRE(relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, nuxt.options.serverDir || 'server'))) + '\\/(api|routes|middleware|plugins)\\/'), - 'Importing from server is not allowed in the Vue part of your app.', + `Importing from server is not allowed in ${context}.`, ]) } return patterns } + +const contextFlags = { + 'nitro-app': 'server runtime', + 'nuxt-app': 'the Vue part of your app', + 'shared': 'the #shared directory', +} as const diff --git a/packages/nuxt/src/imports/module.ts b/packages/nuxt/src/imports/module.ts index af728fb6db..33aad9a885 100644 --- a/packages/nuxt/src/imports/module.ts +++ b/packages/nuxt/src/imports/module.ts @@ -54,6 +54,8 @@ export default defineNuxtModule>({ await nuxt.callHook('imports:context', ctx) + const isNuxtV4 = nuxt.options.future?.compatibilityVersion === 4 + // composables/ dirs from all layers let composablesDirs: string[] = [] if (options.scan) { @@ -64,6 +66,12 @@ export default defineNuxtModule>({ } composablesDirs.push(resolve(layer.config.srcDir, 'composables')) composablesDirs.push(resolve(layer.config.srcDir, 'utils')) + + if (isNuxtV4) { + composablesDirs.push(resolve(layer.config.rootDir, 'shared', 'utils')) + composablesDirs.push(resolve(layer.config.rootDir, 'shared', 'types')) + } + for (const dir of (layer.config.imports?.dirs ?? [])) { if (!dir) { continue diff --git a/packages/nuxt/test/import-protection.test.ts b/packages/nuxt/test/import-protection.test.ts index 110f47dfb1..58d351317e 100644 --- a/packages/nuxt/test/import-protection.test.ts +++ b/packages/nuxt/test/import-protection.test.ts @@ -1,7 +1,7 @@ import { normalize } from 'pathe' import { describe, expect, it } from 'vitest' import { ImpoundPlugin } from 'impound' -import { nuxtImportProtections } from '../src/core/plugins/import-protection' +import { createImportProtectionPatterns } from '../src/core/plugins/import-protection' import type { NuxtOptions } from '../schema' const testsToTriggerOn = [ @@ -28,7 +28,7 @@ const testsToTriggerOn = [ describe('import protection', () => { it.each(testsToTriggerOn)('should protect %s', async (id, importer, isProtected) => { - const result = await transformWithImportProtection(id, importer) + const result = await transformWithImportProtection(id, importer, 'nuxt-app') if (!isProtected) { expect(result).toBeNull() } else { @@ -38,16 +38,16 @@ describe('import protection', () => { }) }) -const transformWithImportProtection = (id: string, importer: string) => { +const transformWithImportProtection = (id: string, importer: string, context: 'nitro-app' | 'nuxt-app' | 'shared') => { const plugin = ImpoundPlugin.rollup({ cwd: '/root', - patterns: nuxtImportProtections({ + patterns: createImportProtectionPatterns({ options: { modules: ['some-nuxt-module'], srcDir: '/root/src/', serverDir: '/root/src/server', } satisfies Partial as NuxtOptions, - }), + }, { context }), }) return (plugin as any).resolveId.call({ error: () => {} }, id, importer) diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index d77d4e8f59..b47834e69c 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -355,6 +355,11 @@ export default defineUntypedSchema({ */ plugins: 'plugins', + /** + * The shared directory. This directory is shared between the app and the server. + */ + shared: 'shared', + /** * The directory containing your static files, which will be directly accessible via the Nuxt server * and copied across into your `dist` folder when your app is generated. @@ -424,12 +429,13 @@ export default defineUntypedSchema({ */ alias: { $resolve: async (val: Record, get): Promise> => { - const [srcDir, rootDir, assetsDir, publicDir, buildDir] = await Promise.all([get('srcDir'), get('rootDir'), get('dir.assets'), get('dir.public'), get('buildDir')]) as [string, string, string, string, string] + const [srcDir, rootDir, assetsDir, publicDir, buildDir, sharedDir] = await Promise.all([get('srcDir'), get('rootDir'), get('dir.assets'), get('dir.public'), get('buildDir'), get('dir.shared')]) as [string, string, string, string, string, string] return { '~': srcDir, '@': srcDir, '~~': rootDir, '@@': rootDir, + '#shared': resolve(rootDir, sharedDir), [basename(assetsDir)]: resolve(srcDir, assetsDir), [basename(publicDir)]: resolve(srcDir, publicDir), '#build': buildDir, diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts index a71976c344..a8da877730 100644 --- a/packages/vite/src/server.ts +++ b/packages/vite/src/server.ts @@ -7,6 +7,7 @@ import { joinURL, withTrailingSlash, withoutLeadingSlash } from 'ufo' import type { ViteConfig } from '@nuxt/schema' import defu from 'defu' import type { Nitro } from 'nitro/types' +import escapeStringRegexp from 'escape-string-regexp' import type { ViteBuildContext } from './vite' import { createViteLogger } from './utils/logger' import { initViteNodeServer } from './vite-node' @@ -80,7 +81,13 @@ export async function buildServer (ctx: ViteBuildContext) { ssr: true, rollupOptions: { input: { server: entry }, - external: ['nitro/runtime', '#internal/nuxt/paths', '#internal/nuxt/app-config'], + external: [ + 'nitro/runtime', + '#internal/nuxt/paths', + '#internal/nuxt/app-config', + '#shared', + new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(ctx.nuxt.options.rootDir, ctx.nuxt.options.dir.shared)))), + ], output: { entryFileNames: '[name].mjs', format: 'module', diff --git a/packages/vite/src/utils/external.ts b/packages/vite/src/utils/external.ts index f8f1320a6f..c6889cb3a1 100644 --- a/packages/vite/src/utils/external.ts +++ b/packages/vite/src/utils/external.ts @@ -1,9 +1,13 @@ 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, rootDir: string, modulesDirs?: string[]) { +export function createIsExternal (viteServer: ViteDevServer, nuxt: Nuxt) { const externalOpts: ExternalsOptions = { inline: [ /virtual:/, @@ -16,15 +20,17 @@ export function createIsExternal (viteServer: ViteDevServer, rootDir: string, mo ), ], external: [ + '#shared', + new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared)))), ...(viteServer.config.ssr.external as string[]) || [], /node_modules/, ], resolve: { - modules: modulesDirs, + modules: nuxt.options.modulesDir, type: 'module', extensions: ['.ts', '.js', '.json', '.vue', '.mjs', '.jsx', '.tsx', '.wasm'], }, } - return (id: string) => isExternal(id, rootDir, externalOpts) + 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 46d7c66ec0..b10a59e884 100644 --- a/packages/vite/src/vite-node.ts +++ b/packages/vite/src/vite-node.ts @@ -140,7 +140,7 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set = ne }, }) - const isExternal = createIsExternal(viteServer, ctx.nuxt.options.rootDir, ctx.nuxt.options.modulesDir) + const isExternal = createIsExternal(viteServer, ctx.nuxt) node.shouldExternalize = async (id: string) => { const result = await isExternal(id) if (result?.external) { diff --git a/packages/webpack/src/configs/server.ts b/packages/webpack/src/configs/server.ts index 53370bef88..c4bda90d59 100644 --- a/packages/webpack/src/configs/server.ts +++ b/packages/webpack/src/configs/server.ts @@ -1,4 +1,4 @@ -import { isAbsolute } from 'pathe' +import { isAbsolute, resolve } from 'pathe' import ForkTSCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin' import { logger } from '@nuxt/kit' import type { WebpackConfigContext } from '../utils/config' @@ -53,7 +53,11 @@ function serverStandalone (ctx: WebpackConfigContext) { '#', ...ctx.options.build.transpile, ] - const external = ['nitro/runtime'] + const external = [ + 'nitro/runtime', + '#shared', + resolve(ctx.nuxt.options.rootDir, ctx.nuxt.options.dir.shared), + ] if (!ctx.nuxt.options.dev) { external.push('#internal/nuxt/paths', '#internal/nuxt/app-config') } From f788b0dfccd113d1a8ef0b72ade6308e77fa256b Mon Sep 17 00:00:00 2001 From: Nils Date: Sat, 2 Nov 2024 22:28:04 +0100 Subject: [PATCH 19/23] feat(nuxt): allow chunk error or manifest update -> reload (#28160) --- .../1.experimental-features.md | 6 +++-- .../plugins/chunk-reload-immediate.client.ts | 23 +++++++++++++++++++ packages/nuxt/src/core/nuxt.ts | 5 ++++ packages/schema/src/config/experimental.ts | 9 +++++--- 4 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 packages/nuxt/src/app/plugins/chunk-reload-immediate.client.ts diff --git a/docs/2.guide/3.going-further/1.experimental-features.md b/docs/2.guide/3.going-further/1.experimental-features.md index 57cd77803e..40d8c5bebf 100644 --- a/docs/2.guide/3.going-further/1.experimental-features.md +++ b/docs/2.guide/3.going-further/1.experimental-features.md @@ -59,14 +59,16 @@ This feature will likely be removed in a near future. ## emitRouteChunkError -Emits `app:chunkError` hook when there is an error loading vite/webpack chunks. Default behavior is to perform a hard reload of the new route when a chunk fails to load. +Emits `app:chunkError` hook when there is an error loading vite/webpack chunks. Default behavior is to perform a reload of the new route on navigation to a new route when a chunk fails to load. + +If you set this to `'automatic-immediate'` Nuxt will reload the current route immediatly, instead of waiting for a navigation. This is useful for chunk errors that are not triggered by navigation, e.g., when your Nuxt app fails to load a [lazy component](/docs/guide/directory-structure/components#dynamic-imports). A potential downside of this behavior is undesired reloads, e.g., when your app does not need the chunk that caused the error. You can disable automatic handling by setting this to `false`, or handle chunk errors manually by setting it to `manual`. ```ts twoslash [nuxt.config.ts] export default defineNuxtConfig({ experimental: { - emitRouteChunkError: 'automatic' // or 'manual' or false + emitRouteChunkError: 'automatic' // or 'automatic-immediate', 'manual' or false } }) ``` diff --git a/packages/nuxt/src/app/plugins/chunk-reload-immediate.client.ts b/packages/nuxt/src/app/plugins/chunk-reload-immediate.client.ts new file mode 100644 index 0000000000..ef2c8946ab --- /dev/null +++ b/packages/nuxt/src/app/plugins/chunk-reload-immediate.client.ts @@ -0,0 +1,23 @@ +import { defineNuxtPlugin } from '../nuxt' +import { reloadNuxtApp } from '../composables/chunk' +import { addRouteMiddleware } from '../composables/router' + +const reloadNuxtApp_ = (path: string) => { reloadNuxtApp({ persistState: true, path }) } + +// See https://github.com/nuxt/nuxt/issues/23612 for more context +export default defineNuxtPlugin({ + name: 'nuxt:chunk-reload-immediate', + setup (nuxtApp) { + // Remember `to.path` when navigating to a new path: A `chunkError` may occur during navigation, we then want to then reload at `to.path` + let currentlyNavigationTo: null | string = null + addRouteMiddleware((to) => { + currentlyNavigationTo = to.path + }) + + // Reload when a `chunkError` is thrown + nuxtApp.hook('app:chunkError', () => reloadNuxtApp_(currentlyNavigationTo ?? nuxtApp._route.path)) + + // Reload when the app manifest updates + nuxtApp.hook('app:manifest:update', () => reloadNuxtApp_(nuxtApp._route.path)) + }, +}) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index ae8e2f9c66..670131cac3 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -576,6 +576,11 @@ async function initNuxt (nuxt: Nuxt) { if (nuxt.options.experimental.emitRouteChunkError === 'automatic') { addPlugin(resolve(nuxt.options.appDir, 'plugins/chunk-reload.client')) } + // Add experimental immediate page reload support + if (nuxt.options.experimental.emitRouteChunkError === 'automatic-immediate') { + addPlugin(resolve(nuxt.options.appDir, 'plugins/chunk-reload-immediate.client')) + } + // Add experimental session restoration support if (nuxt.options.experimental.restoreState) { addPlugin(resolve(nuxt.options.appDir, 'plugins/restore-state.client')) diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 5743c313f0..00ae9e2e0c 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -116,13 +116,16 @@ export default defineUntypedSchema({ * Emit `app:chunkError` hook when there is an error loading vite/webpack * chunks. * - * By default, Nuxt will also perform a hard reload of the new route - * when a chunk fails to load when navigating to a new route. + * By default, Nuxt will also perform a reload of the new route + * when a chunk fails to load when navigating to a new route (`automatic`). + * + * Setting `automatic-immediate` will lead Nuxt to perform a reload of the current route + * right when a chunk fails to load (instead of waiting for navigation). * * You can disable automatic handling by setting this to `false`, or handle * chunk errors manually by setting it to `manual`. * @see [Nuxt PR #19038](https://github.com/nuxt/nuxt/pull/19038) - * @type {false | 'manual' | 'automatic'} + * @type {false | 'manual' | 'automatic' | 'automatic-immediate'} */ emitRouteChunkError: { $resolve: (val) => { From 1ed96746800fb2ed601d8c39c026ae02fcf2c77f Mon Sep 17 00:00:00 2001 From: Till Sanders Date: Sat, 2 Nov 2024 22:41:09 +0100 Subject: [PATCH 20/23] docs: add information on `--envName` flag (#28909) --- docs/1.getting-started/3.configuration.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/1.getting-started/3.configuration.md b/docs/1.getting-started/3.configuration.md index 4cf9310b81..7be966f8c9 100644 --- a/docs/1.getting-started/3.configuration.md +++ b/docs/1.getting-started/3.configuration.md @@ -41,10 +41,17 @@ export default defineNuxtConfig({ }, $development: { // - } + }, + $myCustomName: { + // + }, }) ``` +To select an environment when running a Nuxt CLI command, simply pass the name to the `--envName` flag, like so: `nuxi build --envName myCustomName`. + +To learn more about the mechanism behind these overrides, please refer to the `c12` documentation on [environment-specific configuration](https://github.com/unjs/c12?tab=readme-ov-file#environment-specific-configuration). + ::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=DFZI2iVCrNc" target="_blank"} Watch a video from Alexander Lichter about the env-aware `nuxt.config.ts`. :: From edef8327002fc55c976b494249c452d13a008914 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 2 Nov 2024 21:58:20 +0000 Subject: [PATCH 21/23] docs: add error expectation --- docs/1.getting-started/3.configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/1.getting-started/3.configuration.md b/docs/1.getting-started/3.configuration.md index 7be966f8c9..57eb019f6c 100644 --- a/docs/1.getting-started/3.configuration.md +++ b/docs/1.getting-started/3.configuration.md @@ -33,6 +33,7 @@ You don't have to use TypeScript to build an application with Nuxt. However, it You can configure fully typed, per-environment overrides in your nuxt.config ```ts twoslash [nuxt.config.ts] +// @errors: 2353 export default defineNuxtConfig({ $production: { routeRules: { From 2aa4daab92aa592fff71bad52f0393b6759bb7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20G=C5=82owala?= Date: Sat, 2 Nov 2024 23:25:05 +0100 Subject: [PATCH 22/23] feat(nuxt): add `useRuntimeHook` composable (#29741) --- docs/3.api/2.composables/use-runtime-hook.md | 43 +++++++++++++++++++ packages/nuxt/src/app/composables/index.ts | 1 + .../nuxt/src/app/composables/runtime-hook.ts | 21 +++++++++ packages/nuxt/src/app/index.ts | 2 +- packages/nuxt/src/app/nuxt.ts | 2 +- packages/nuxt/src/imports/presets.ts | 4 ++ test/nuxt/composables.test.ts | 32 ++++++++++++++ 7 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 docs/3.api/2.composables/use-runtime-hook.md create mode 100644 packages/nuxt/src/app/composables/runtime-hook.ts diff --git a/docs/3.api/2.composables/use-runtime-hook.md b/docs/3.api/2.composables/use-runtime-hook.md new file mode 100644 index 0000000000..c2b3a9ec59 --- /dev/null +++ b/docs/3.api/2.composables/use-runtime-hook.md @@ -0,0 +1,43 @@ +--- +title: useRuntimeHook +description: Registers a runtime hook in a Nuxt application and ensures it is properly disposed of when the scope is destroyed. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/runtime-hook.ts + size: xs +--- + +::important +This composable is available in Nuxt v3.14+. +:: + +```ts [signature] +function useRuntimeHook( + name: THookName, + fn: RuntimeNuxtHooks[THookName] extends HookCallback ? RuntimeNuxtHooks[THookName] : never +): void +``` + +## Usage + +### Parameters + +- `name`: The name of the runtime hook to register. You can see the full list of [runtime Nuxt hooks here](/docs/api/advanced/hooks#app-hooks-runtime). +- `fn`: The callback function to execute when the hook is triggered. The function signature varies based on the hook name. + +### Returns + +The composable doesn't return a value, but it automatically unregisters the hook when the component's scope is destroyed. + +## Example + +```vue twoslash [pages/index.vue] + +``` diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 05ba560d09..7d5e0e317f 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -38,3 +38,4 @@ export { useRequestURL } from './url' export { usePreviewMode } from './preview' export { useId } from './id' export { useRouteAnnouncer } from './route-announcer' +export { useRuntimeHook } from './runtime-hook' diff --git a/packages/nuxt/src/app/composables/runtime-hook.ts b/packages/nuxt/src/app/composables/runtime-hook.ts new file mode 100644 index 0000000000..a1249efeda --- /dev/null +++ b/packages/nuxt/src/app/composables/runtime-hook.ts @@ -0,0 +1,21 @@ +import { onScopeDispose } from 'vue' +import type { HookCallback } from 'hookable' +import { useNuxtApp } from '../nuxt' +import type { RuntimeNuxtHooks } from '../nuxt' + +/** + * Registers a runtime hook in a Nuxt application and ensures it is properly disposed of when the scope is destroyed. + * @param name - The name of the hook to register. + * @param fn - The callback function to be executed when the hook is triggered. + * @since 3.14.0 + */ +export function useRuntimeHook ( + name: THookName, + fn: RuntimeNuxtHooks[THookName] extends HookCallback ? RuntimeNuxtHooks[THookName] : never, +): void { + const nuxtApp = useNuxtApp() + + const unregister = nuxtApp.hook(name, fn) + + onScopeDispose(unregister) +} diff --git a/packages/nuxt/src/app/index.ts b/packages/nuxt/src/app/index.ts index 43fcc404e1..c530599b28 100644 --- a/packages/nuxt/src/app/index.ts +++ b/packages/nuxt/src/app/index.ts @@ -1,7 +1,7 @@ export { applyPlugin, applyPlugins, callWithNuxt, createNuxtApp, defineAppConfig, defineNuxtPlugin, definePayloadPlugin, isNuxtPlugin, registerPluginHooks, tryUseNuxtApp, useNuxtApp, useRuntimeConfig } from './nuxt' export type { CreateOptions, NuxtApp, NuxtPayload, NuxtPluginIndicator, NuxtSSRContext, ObjectPlugin, Plugin, PluginEnvContext, PluginMeta, ResolvedPluginMeta, RuntimeNuxtHooks } from './nuxt' -export { defineNuxtComponent, useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData, useHydration, callOnce, useState, clearNuxtState, clearError, createError, isNuxtError, showError, useError, useFetch, useLazyFetch, useCookie, refreshCookie, onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus, useResponseHeader, onNuxtReady, abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter, preloadComponents, prefetchComponents, preloadRouteComponents, isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver, getAppManifest, getRouteRules, reloadNuxtApp, useRequestURL, usePreviewMode, useId, useRouteAnnouncer, useHead, useSeoMeta, useServerSeoMeta } from './composables/index' +export { defineNuxtComponent, useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData, useHydration, callOnce, useState, clearNuxtState, clearError, createError, isNuxtError, showError, useError, useFetch, useLazyFetch, useCookie, refreshCookie, onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus, useResponseHeader, onNuxtReady, abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter, preloadComponents, prefetchComponents, preloadRouteComponents, isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver, getAppManifest, getRouteRules, reloadNuxtApp, useRequestURL, usePreviewMode, useId, useRouteAnnouncer, useHead, useSeoMeta, useServerSeoMeta, useRuntimeHook } from './composables/index' export type { AddRouteMiddlewareOptions, AsyncData, AsyncDataOptions, AsyncDataRequestStatus, CookieOptions, CookieRef, FetchResult, NuxtAppManifest, NuxtAppManifestMeta, NuxtError, ReloadNuxtAppOptions, RouteMiddleware, UseFetchOptions } from './composables/index' export { defineNuxtLink } from './components/index' diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index a903987a01..4faff0a1bc 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -45,7 +45,7 @@ export interface RuntimeNuxtHooks { 'app:chunkError': (options: { error: any }) => HookResult 'app:data:refresh': (keys?: string[]) => HookResult 'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult - 'dev:ssr-logs': (logs: LogObject[]) => void | Promise + 'dev:ssr-logs': (logs: LogObject[]) => HookResult 'link:prefetch': (link: string) => HookResult 'page:start': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index b98b96f128..d04c2badc1 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -109,6 +109,10 @@ const granularAppPresets: InlinePreset[] = [ imports: ['useRouteAnnouncer'], from: '#app/composables/route-announcer', }, + { + imports: ['useRuntimeHook'], + from: '#app/composables/runtime-hook', + }, ] export const scriptsStubsPreset = { diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index 1e56ce4902..43fe151a92 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -20,6 +20,7 @@ import { callOnce } from '#app/composables/once' import { useLoadingIndicator } from '#app/composables/loading-indicator' import { useRouteAnnouncer } from '#app/composables/route-announcer' import { encodeURL, resolveRouteObject } from '#app/composables/router' +import { useRuntimeHook } from '#app/composables/runtime-hook' registerEndpoint('/api/test', defineEventHandler(event => ({ method: event.method, @@ -93,6 +94,7 @@ describe('composables', () => { 'abortNavigation', 'setPageLayout', 'defineNuxtComponent', + 'useRuntimeHook', ] const skippedComposables: string[] = [ 'addRouteMiddleware', @@ -577,6 +579,36 @@ describe.skipIf(process.env.TEST_MANIFEST === 'manifest-off')('app manifests', ( }) }) +describe('useRuntimeHook', () => { + it('types work', () => { + // @ts-expect-error should not allow unknown hooks + useRuntimeHook('test', () => {}) + useRuntimeHook('app:beforeMount', (_app) => { + // @ts-expect-error argument should be typed + _app = 'test' + }) + }) + + it('should call hooks', async () => { + const nuxtApp = useNuxtApp() + let called = 1 + const wrapper = await mountSuspended(defineNuxtComponent({ + setup () { + useRuntimeHook('test-hook' as any, () => { + called++ + }) + }, + render: () => h('div', 'hi there'), + })) + expect(called).toBe(1) + await nuxtApp.callHook('test-hook' as any) + expect(called).toBe(2) + wrapper.unmount() + await nuxtApp.callHook('test-hook' as any) + expect(called).toBe(2) + }) +}) + describe('routing utilities: `navigateTo`', () => { it('navigateTo should disallow navigation to external URLs by default', () => { expect(() => navigateTo('https://test.com')).toThrowErrorMatchingInlineSnapshot('[Error: Navigating to an external URL is not allowed by default. Use `navigateTo(url, { external: true })`.]') From cbafa582a3b98b95aad09ff21d1da3bd99beee2f Mon Sep 17 00:00:00 2001 From: xjccc <546534045@qq.com> Date: Sun, 3 Nov 2024 07:38:54 +0800 Subject: [PATCH 23/23] fix(nuxt): respect existing `props` value in `definePageMeta` (#29683) --- packages/nuxt/src/pages/utils.ts | 5 +++-- .../__snapshots__/pages-override-meta-disabled.test.ts.snap | 2 +- .../__snapshots__/pages-override-meta-enabled.test.ts.snap | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index d3d62b4ee4..3f8ad83851 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -507,13 +507,14 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = const route: NormalizedRoute = { path: serializeRouteValue(page.path), + props: serializeRouteValue(page.props), name: serializeRouteValue(page.name), meta: serializeRouteValue(metaFiltered, skipMeta), alias: serializeRouteValue(toArray(page.alias), skipAlias), redirect: serializeRouteValue(page.redirect), } - for (const key of ['path', 'name', 'meta', 'alias', 'redirect'] satisfies NormalizedRouteKeys) { + for (const key of ['path', 'props', 'name', 'meta', 'alias', 'redirect'] satisfies NormalizedRouteKeys) { if (route[key] === undefined) { delete route[key] } @@ -542,7 +543,7 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = const metaRoute: NormalizedRoute = { name: `${metaImportName}?.name ?? ${route.name}`, path: `${metaImportName}?.path ?? ${route.path}`, - props: `${metaImportName}?.props ?? false`, + props: `${metaImportName}?.props ?? ${route.props ?? false}`, meta: `${metaImportName} || {}`, alias: `${metaImportName}?.alias || []`, redirect: `${metaImportName}?.redirect`, diff --git a/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap b/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap index 7dd113b1af..9cf64b5264 100644 --- a/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap +++ b/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap @@ -36,7 +36,7 @@ "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "page-with-props"", "path": "mockMeta?.path ?? "/page-with-props"", - "props": "mockMeta?.props ?? false", + "props": "mockMeta?.props ?? true", "redirect": "mockMeta?.redirect", }, ], diff --git a/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap b/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap index d02977f9b3..2247a9e4c7 100644 --- a/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap +++ b/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap @@ -29,6 +29,7 @@ "component": "() => import("pages/page-with-props.vue")", "name": ""page-with-props"", "path": ""/page-with-props"", + "props": "true", }, ], "should allow pages with `:` in their path": [