diff --git a/docs/3.api/1.components/5.nuxt-loading-indicator.md b/docs/3.api/1.components/5.nuxt-loading-indicator.md index 6d0d8d569389..a3576510b97c 100644 --- a/docs/3.api/1.components/5.nuxt-loading-indicator.md +++ b/docs/3.api/1.components/5.nuxt-loading-indicator.md @@ -40,3 +40,7 @@ You can pass custom HTML or components through the loading indicator's default s This component is optional. :br To achieve full customization, you can implement your own one based on [its source code](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/components/nuxt-loading-indicator.ts). :: + +::callout +You can hook into the underlying indicator instance using [the `useLoadingIndicator` composable](/docs/api/composables/use-loading-indicator), which will allow you to trigger start/finish events yourself. +:: diff --git a/docs/3.api/2.composables/use-loading-indicator.md b/docs/3.api/2.composables/use-loading-indicator.md new file mode 100644 index 000000000000..e468c8a5d058 --- /dev/null +++ b/docs/3.api/2.composables/use-loading-indicator.md @@ -0,0 +1,40 @@ +--- +title: 'useLoadingIndicator' +description: This composable gives you access to the loading state of the app page. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/loading-indicator.ts + size: xs +--- + +## Description + +A composable which returns the loading state of the page. Used by [``](/docs/api/components/nuxt-loading-indicator) and controllable. +It hooks into [`page:loading:start`](/docs/api/advanced/hooks#app-hooks-runtime) and [`page:loading:end`](/docs/api/advanced/hooks#app-hooks-runtime) to change its state. + +## Properties + +### `isLoading` + +- **type**: `Ref` +- **description**: The loading state + +### `progress` + +- **type**: `Ref` +- **description**: The progress state. From `0` to `100`. + +## Methods + +### `start()` + +Set `isLoading` to true and start to increase the `progress` value. + +### `finish()` + +Set the `progress` value to `100`, stop all timers and intervals then reset the loading state `500` ms later. + +### `clear()` + +Used by `finish()`. Clear all timers and intervals used by the composable. diff --git a/docs/3.api/6.advanced/1.hooks.md b/docs/3.api/6.advanced/1.hooks.md index 1cc1e85b048a..79dcc6e96389 100644 --- a/docs/3.api/6.advanced/1.hooks.md +++ b/docs/3.api/6.advanced/1.hooks.md @@ -25,6 +25,8 @@ Hook | Arguments | Environment | Description `link:prefetch` | `to` | Client | Called when a `` is observed to be prefetched. `page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event. `page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event. +`page:loading:start` | - | Client | Called when the `setup()` of the new page is running. +`page:loading:end` | - | Client | Called after `page:finish` `page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event. ## Nuxt Hooks (build time) diff --git a/packages/nuxt/src/app/components/nuxt-loading-indicator.ts b/packages/nuxt/src/app/components/nuxt-loading-indicator.ts index 83f454af93b6..926f063559af 100644 --- a/packages/nuxt/src/app/components/nuxt-loading-indicator.ts +++ b/packages/nuxt/src/app/components/nuxt-loading-indicator.ts @@ -1,10 +1,5 @@ -import { computed, defineComponent, h, onBeforeUnmount, ref } from 'vue' -import { useNuxtApp } from '../nuxt' -import { useRouter } from '../composables/router' -import { isChangingPage } from './utils' - -// @ts-expect-error virtual file -import { globalMiddleware } from '#build/middleware' +import { defineComponent, h } from 'vue' +import { useLoadingIndicator } from '#app/composables/loading-indicator' export default defineComponent({ name: 'NuxtLoadingIndicator', @@ -26,48 +21,15 @@ export default defineComponent({ default: 'repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)' } }, - setup (props, { slots }) { - // TODO: use computed values in useLoadingIndicator + setup (props, { slots, expose }) { const { progress, isLoading, start, finish, clear } = useLoadingIndicator({ duration: props.duration, throttle: props.throttle }) - if (import.meta.client) { - // Hook to app lifecycle - // TODO: Use unified loading API - const nuxtApp = useNuxtApp() - const router = useRouter() - - globalMiddleware.unshift(start) - router.onError(() => { - finish() - }) - router.beforeResolve((to, from) => { - if (!isChangingPage(to, from)) { - finish() - } - }) - - router.afterEach((_to, _from, failure) => { - if (failure) { - finish() - } - }) - - const unsubPage = nuxtApp.hook('page:finish', finish) - const unsubError = nuxtApp.hook('vue:error', finish) - - onBeforeUnmount(() => { - const index = globalMiddleware.indexOf(start) - if (index >= 0) { - globalMiddleware.splice(index, 1) - } - unsubPage() - unsubError() - clear() - }) - } + expose({ + progress, isLoading, start, finish, clear + }) return () => h('div', { class: 'nuxt-loading-indicator', @@ -90,68 +52,3 @@ export default defineComponent({ }, slots) } }) - -function useLoadingIndicator (opts: { - duration: number, - throttle: number -}) { - const progress = ref(0) - const isLoading = ref(false) - const step = computed(() => 10000 / opts.duration) - - let _timer: any = null - let _throttle: any = null - - function start () { - clear() - progress.value = 0 - if (opts.throttle && import.meta.client) { - _throttle = setTimeout(() => { - isLoading.value = true - _startTimer() - }, opts.throttle) - } else { - isLoading.value = true - _startTimer() - } - } - function finish () { - progress.value = 100 - _hide() - } - - function clear () { - clearInterval(_timer) - clearTimeout(_throttle) - _timer = null - _throttle = null - } - - function _increase (num: number) { - progress.value = Math.min(100, progress.value + num) - } - - function _hide () { - clear() - if (import.meta.client) { - setTimeout(() => { - isLoading.value = false - setTimeout(() => { progress.value = 0 }, 400) - }, 500) - } - } - - function _startTimer () { - if (import.meta.client) { - _timer = setInterval(() => { _increase(step.value) }, 100) - } - } - - return { - progress, - isLoading, - start, - finish, - clear - } -} diff --git a/packages/nuxt/src/app/composables/loading-indicator.ts b/packages/nuxt/src/app/composables/loading-indicator.ts new file mode 100644 index 000000000000..b9290ba123c6 --- /dev/null +++ b/packages/nuxt/src/app/composables/loading-indicator.ts @@ -0,0 +1,134 @@ +import { computed, getCurrentScope, onScopeDispose, ref } from 'vue' +import type { Ref } from 'vue' +import { useNuxtApp } from '#app/nuxt' + +export type LoadingIndicatorOpts = { + /** @default 2000 */ + duration: number + /** @default 200 */ + throttle: number +} + +function _increase (progress: Ref, num: number) { + progress.value = Math.min(100, progress.value + num) +} + +function _hide (isLoading: Ref, progress: Ref) { + if (import.meta.client) { + setTimeout(() => { + isLoading.value = false + setTimeout(() => { progress.value = 0 }, 400) + }, 500) + } +} + +export type LoadingIndicator = { + _cleanup: () => void + progress: Ref + isLoading: Ref + start: () => void + set: (value: number) => void + finish: () => void + clear: () => void +} + +function createLoadingIndicator (opts: Partial = {}) { + const { duration = 2000, throttle = 200 } = opts + const nuxtApp = useNuxtApp() + const progress = ref(0) + const isLoading = ref(false) + const step = computed(() => 10000 / duration) + + let _timer: any = null + let _throttle: any = null + + const start = () => set(0) + + function set (at = 0) { + if (nuxtApp.isHydrating) { + return + } + if (at >= 100) { return finish() } + clear() + progress.value = at < 0 ? 0 : at + if (throttle && import.meta.client) { + _throttle = setTimeout(() => { + isLoading.value = true + _startTimer() + }, throttle) + } else { + isLoading.value = true + _startTimer() + } + } + + function finish () { + progress.value = 100 + clear() + _hide(isLoading, progress) + } + + function clear () { + clearInterval(_timer) + clearTimeout(_throttle) + _timer = null + _throttle = null + } + + function _startTimer () { + if (import.meta.client) { + _timer = setInterval(() => { _increase(progress, step.value) }, 100) + } + } + + let _cleanup = () => {} + if (import.meta.client) { + const unsubLoadingStartHook = nuxtApp.hook('page:loading:start', () => { + start() + }) + const unsubLoadingFinishHook = nuxtApp.hook('page:loading:end', () => { + finish() + }) + const unsubError = nuxtApp.hook('vue:error', finish) + + _cleanup = () => { + unsubError() + unsubLoadingStartHook() + unsubLoadingFinishHook() + clear() + } + } + + return { + _cleanup, + progress: computed(() => progress.value), + isLoading: computed(() => isLoading.value), + start, + set, + finish, + clear + } +} + +/** + * composable to handle the loading state of the page + */ +export function useLoadingIndicator (opts: Partial = {}): Omit { + const nuxtApp = useNuxtApp() + + // Initialise global loading indicator if it doesn't exist already + const indicator = nuxtApp._loadingIndicator = nuxtApp._loadingIndicator || createLoadingIndicator(opts) + if (import.meta.client && getCurrentScope()) { + nuxtApp._loadingIndicatorDeps = nuxtApp._loadingIndicatorDeps || 0 + nuxtApp._loadingIndicatorDeps++ + onScopeDispose(() => { + nuxtApp._loadingIndicatorDeps!-- + if (nuxtApp._loadingIndicatorDeps === 0) { + indicator._cleanup() + delete nuxtApp._loadingIndicator + } + }) + } + + return indicator +} diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 878b1f2f9a0a..8ce0d22f06b0 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -17,6 +17,7 @@ import type { RouteMiddleware } from '../app/composables/router' import type { NuxtError } from '../app/composables/error' import type { AsyncDataRequestStatus } from '../app/composables/asyncData' import type { NuxtAppManifestMeta } from '../app/composables/manifest' +import type { LoadingIndicator } from '#app/composables/loading-indicator' import type { NuxtAppLiterals } from '#app' @@ -44,6 +45,8 @@ export interface RuntimeNuxtHooks { 'page:finish': (Component?: VNode) => HookResult 'page:transition:start': () => HookResult 'page:transition:finish': (Component?: VNode) => HookResult + 'page:loading:start': () => HookResult + 'page:loading:end': () => HookResult 'vue:setup': () => void 'vue:error': (...args: Parameters[0]>) => HookResult } @@ -112,6 +115,11 @@ interface _NuxtApp { status: Ref } | undefined> + /** @internal */ + _loadingIndicator?: LoadingIndicator + /** @internal */ + _loadingIndicatorDeps?: number + /** @internal */ _middleware: { global: RouteMiddleware[] diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 4f896207131a..0962a69f9222 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -77,6 +77,10 @@ const granularAppPresets: InlinePreset[] = [ imports: ['isPrerendered', 'loadPayload', 'preloadPayload', 'definePayloadReducer', 'definePayloadReviver'], from: '#app/composables/payload' }, + { + imports: ['useLoadingIndicator'], + from: '#app/composables/loading-indicator' + }, { imports: ['getAppManifest', 'getRouteRules'], from: '#app/composables/manifest' diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts index 9c97d02b777e..63ab6607a78e 100644 --- a/packages/nuxt/src/pages/runtime/page.ts +++ b/packages/nuxt/src/pages/runtime/page.ts @@ -1,4 +1,4 @@ -import { Suspense, Transition, defineComponent, h, inject, nextTick, ref } from 'vue' +import { Suspense, Transition, defineComponent, h, inject, nextTick, ref, watch } from 'vue' import type { KeepAliveProps, TransitionProps, VNode } from 'vue' import { RouterView } from '#vue-router' import { defu } from 'defu' @@ -48,6 +48,14 @@ export default defineComponent({ const done = nuxtApp.deferHydration() + if (props.pageKey) { + watch(() => props.pageKey, (next, prev) => { + if (next !== prev) { + nuxtApp.callHook('page:loading:start') + } + }) + } + return () => { return h(RouterView, { name: props.name, route: props.route, ...attrs }, { default: (routeProps: RouterViewSlotProps) => { @@ -93,7 +101,7 @@ export default defineComponent({ wrapInKeepAlive(keepaliveConfig, h(Suspense, { suspensible: true, onPending: () => nuxtApp.callHook('page:start', routeProps.Component), - onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).finally(done)) } + onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).then(() => nuxtApp.callHook('page:loading:end')).finally(done)) } }, { default: () => { const providerVNode = h(RouteProvider, { diff --git a/packages/nuxt/src/pages/runtime/plugins/router.ts b/packages/nuxt/src/pages/runtime/plugins/router.ts index 4c5934edc2a1..6b7c9c425b47 100644 --- a/packages/nuxt/src/pages/runtime/plugins/router.ts +++ b/packages/nuxt/src/pages/runtime/plugins/router.ts @@ -142,6 +142,7 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ const initialLayout = nuxtApp.payload.state._layout router.beforeEach(async (to, from) => { + await nuxtApp.callHook('page:loading:start') to.meta = reactive(to.meta) if (nuxtApp.isHydrating && initialLayout && !isReadonly(to.meta.layout)) { to.meta.layout = initialLayout as Exclude @@ -193,7 +194,10 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ } }) - router.onError(() => { delete nuxtApp._processingMiddleware }) + router.onError(async () => { + delete nuxtApp._processingMiddleware + await nuxtApp.callHook('page:loading:end') + }) router.afterEach(async (to, _from, failure) => { delete nuxtApp._processingMiddleware @@ -202,6 +206,9 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ // Clear any existing errors await nuxtApp.runWithContext(clearError) } + if (failure) { + await nuxtApp.callHook('page:loading:end') + } if (import.meta.server && failure?.type === 4 /* ErrorTypes.NAVIGATION_ABORTED */) { return } diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index 43f6ef2deace..65573137a580 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -14,6 +14,7 @@ import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders import { clearNuxtState, useState } from '#app/composables/state' import { useRequestURL } from '#app/composables/url' import { getAppManifest, getRouteRules } from '#app/composables/manifest' +import { useLoadingIndicator } from '#app/composables/loading-indicator' vi.mock('#app/compat/idle-callback', () => ({ requestIdleCallback: (cb: Function) => cb() @@ -438,6 +439,21 @@ describe('url', () => { }) }) +describe('loading state', () => { + it('expect loading state to be changed by hooks', async () => { + vi.stubGlobal('setTimeout', vi.fn((cb: Function) => cb())) + const nuxtApp = useNuxtApp() + const { isLoading } = useLoadingIndicator() + expect(isLoading.value).toBeFalsy() + await nuxtApp.callHook('page:loading:start') + expect(isLoading.value).toBeTruthy() + + await nuxtApp.callHook('page:loading:end') + expect(isLoading.value).toBeFalsy() + vi.mocked(setTimeout).mockRestore() + }) +}) + describe.skipIf(process.env.TEST_MANIFEST === 'manifest-off')('app manifests', () => { it('getAppManifest', async () => { const manifest = await getAppManifest()