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 })`.]')