mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): move loading api behind hooks (#24010)
This commit is contained in:
parent
3be4a5d406
commit
9cd6c922e5
@ -38,3 +38,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.
|
||||
::
|
||||
|
40
docs/3.api/2.composables/use-loading-indicator.md
Normal file
40
docs/3.api/2.composables/use-loading-indicator.md
Normal file
@ -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 [`<NuxtLoadingIndicator>`](/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<boolean>`
|
||||
- **description**: The loading state
|
||||
|
||||
### `progress`
|
||||
|
||||
- **type**: `Ref<number>`
|
||||
- **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.
|
@ -25,6 +25,8 @@ Hook | Arguments | Environment | Description
|
||||
`link:prefetch` | `to` | Client | Called when a `<NuxtLink>` 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)
|
||||
|
@ -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()
|
||||
expose({
|
||||
progress, isLoading, start, finish, clear
|
||||
})
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
134
packages/nuxt/src/app/composables/loading-indicator.ts
Normal file
134
packages/nuxt/src/app/composables/loading-indicator.ts
Normal file
@ -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<number>, num: number) {
|
||||
progress.value = Math.min(100, progress.value + num)
|
||||
}
|
||||
|
||||
function _hide (isLoading: Ref<boolean>, progress: Ref<number>) {
|
||||
if (import.meta.client) {
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
setTimeout(() => { progress.value = 0 }, 400)
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
export type LoadingIndicator = {
|
||||
_cleanup: () => void
|
||||
progress: Ref<number>
|
||||
isLoading: Ref<boolean>
|
||||
start: () => void
|
||||
set: (value: number) => void
|
||||
finish: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function createLoadingIndicator (opts: Partial<LoadingIndicatorOpts> = {}) {
|
||||
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<LoadingIndicatorOpts> = {}): Omit<LoadingIndicator, '_cleanup'> {
|
||||
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
|
||||
}
|
@ -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<Parameters<typeof onErrorCaptured>[0]>) => HookResult
|
||||
}
|
||||
@ -112,6 +115,11 @@ interface _NuxtApp {
|
||||
status: Ref<AsyncDataRequestStatus>
|
||||
} | undefined>
|
||||
|
||||
/** @internal */
|
||||
_loadingIndicator?: LoadingIndicator
|
||||
/** @internal */
|
||||
_loadingIndicatorDeps?: number
|
||||
|
||||
/** @internal */
|
||||
_middleware: {
|
||||
global: RouteMiddleware[]
|
||||
|
@ -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'
|
||||
|
@ -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, {
|
||||
|
@ -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<PageMeta['layout'], Ref | false>
|
||||
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user