feat(nuxt): move loading api behind hooks (#24010)

This commit is contained in:
Julien Huang 2023-12-19 11:18:10 +01:00 committed by GitHub
parent 3be4a5d406
commit 9cd6c922e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 232 additions and 112 deletions

View File

@ -38,3 +38,7 @@ You can pass custom HTML or components through the loading indicator's default s
This component is optional. :br 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). 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.
::

View 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.

View File

@ -25,6 +25,8 @@ Hook | Arguments | Environment | Description
`link:prefetch` | `to` | Client | Called when a `<NuxtLink>` is observed to be prefetched. `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: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: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. `page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event.
## Nuxt Hooks (build time) ## Nuxt Hooks (build time)

View File

@ -1,10 +1,5 @@
import { computed, defineComponent, h, onBeforeUnmount, ref } from 'vue' import { defineComponent, h } from 'vue'
import { useNuxtApp } from '../nuxt' import { useLoadingIndicator } from '#app/composables/loading-indicator'
import { useRouter } from '../composables/router'
import { isChangingPage } from './utils'
// @ts-expect-error virtual file
import { globalMiddleware } from '#build/middleware'
export default defineComponent({ export default defineComponent({
name: 'NuxtLoadingIndicator', name: 'NuxtLoadingIndicator',
@ -26,48 +21,15 @@ export default defineComponent({
default: 'repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)' default: 'repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)'
} }
}, },
setup (props, { slots }) { setup (props, { slots, expose }) {
// TODO: use computed values in useLoadingIndicator
const { progress, isLoading, start, finish, clear } = useLoadingIndicator({ const { progress, isLoading, start, finish, clear } = useLoadingIndicator({
duration: props.duration, duration: props.duration,
throttle: props.throttle throttle: props.throttle
}) })
if (import.meta.client) { expose({
// Hook to app lifecycle progress, isLoading, start, finish, clear
// 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()
})
}
return () => h('div', { return () => h('div', {
class: 'nuxt-loading-indicator', class: 'nuxt-loading-indicator',
@ -90,68 +52,3 @@ export default defineComponent({
}, slots) }, 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
}
}

View 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
}

View File

@ -17,6 +17,7 @@ import type { RouteMiddleware } from '../app/composables/router'
import type { NuxtError } from '../app/composables/error' import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData' import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
import type { NuxtAppManifestMeta } from '../app/composables/manifest' import type { NuxtAppManifestMeta } from '../app/composables/manifest'
import type { LoadingIndicator } from '#app/composables/loading-indicator'
import type { NuxtAppLiterals } from '#app' import type { NuxtAppLiterals } from '#app'
@ -44,6 +45,8 @@ export interface RuntimeNuxtHooks {
'page:finish': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult
'page:transition:start': () => HookResult 'page:transition:start': () => HookResult
'page:transition:finish': (Component?: VNode) => HookResult 'page:transition:finish': (Component?: VNode) => HookResult
'page:loading:start': () => HookResult
'page:loading:end': () => HookResult
'vue:setup': () => void 'vue:setup': () => void
'vue:error': (...args: Parameters<Parameters<typeof onErrorCaptured>[0]>) => HookResult 'vue:error': (...args: Parameters<Parameters<typeof onErrorCaptured>[0]>) => HookResult
} }
@ -112,6 +115,11 @@ interface _NuxtApp {
status: Ref<AsyncDataRequestStatus> status: Ref<AsyncDataRequestStatus>
} | undefined> } | undefined>
/** @internal */
_loadingIndicator?: LoadingIndicator
/** @internal */
_loadingIndicatorDeps?: number
/** @internal */ /** @internal */
_middleware: { _middleware: {
global: RouteMiddleware[] global: RouteMiddleware[]

View File

@ -77,6 +77,10 @@ const granularAppPresets: InlinePreset[] = [
imports: ['isPrerendered', 'loadPayload', 'preloadPayload', 'definePayloadReducer', 'definePayloadReviver'], imports: ['isPrerendered', 'loadPayload', 'preloadPayload', 'definePayloadReducer', 'definePayloadReviver'],
from: '#app/composables/payload' from: '#app/composables/payload'
}, },
{
imports: ['useLoadingIndicator'],
from: '#app/composables/loading-indicator'
},
{ {
imports: ['getAppManifest', 'getRouteRules'], imports: ['getAppManifest', 'getRouteRules'],
from: '#app/composables/manifest' from: '#app/composables/manifest'

View File

@ -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 type { KeepAliveProps, TransitionProps, VNode } from 'vue'
import { RouterView } from '#vue-router' import { RouterView } from '#vue-router'
import { defu } from 'defu' import { defu } from 'defu'
@ -48,6 +48,14 @@ export default defineComponent({
const done = nuxtApp.deferHydration() const done = nuxtApp.deferHydration()
if (props.pageKey) {
watch(() => props.pageKey, (next, prev) => {
if (next !== prev) {
nuxtApp.callHook('page:loading:start')
}
})
}
return () => { return () => {
return h(RouterView, { name: props.name, route: props.route, ...attrs }, { return h(RouterView, { name: props.name, route: props.route, ...attrs }, {
default: (routeProps: RouterViewSlotProps) => { default: (routeProps: RouterViewSlotProps) => {
@ -93,7 +101,7 @@ export default defineComponent({
wrapInKeepAlive(keepaliveConfig, h(Suspense, { wrapInKeepAlive(keepaliveConfig, h(Suspense, {
suspensible: true, suspensible: true,
onPending: () => nuxtApp.callHook('page:start', routeProps.Component), 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: () => { default: () => {
const providerVNode = h(RouteProvider, { const providerVNode = h(RouteProvider, {

View File

@ -142,6 +142,7 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({
const initialLayout = nuxtApp.payload.state._layout const initialLayout = nuxtApp.payload.state._layout
router.beforeEach(async (to, from) => { router.beforeEach(async (to, from) => {
await nuxtApp.callHook('page:loading:start')
to.meta = reactive(to.meta) to.meta = reactive(to.meta)
if (nuxtApp.isHydrating && initialLayout && !isReadonly(to.meta.layout)) { if (nuxtApp.isHydrating && initialLayout && !isReadonly(to.meta.layout)) {
to.meta.layout = initialLayout as Exclude<PageMeta['layout'], Ref | false> 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) => { router.afterEach(async (to, _from, failure) => {
delete nuxtApp._processingMiddleware delete nuxtApp._processingMiddleware
@ -202,6 +206,9 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({
// Clear any existing errors // Clear any existing errors
await nuxtApp.runWithContext(clearError) await nuxtApp.runWithContext(clearError)
} }
if (failure) {
await nuxtApp.callHook('page:loading:end')
}
if (import.meta.server && failure?.type === 4 /* ErrorTypes.NAVIGATION_ABORTED */) { if (import.meta.server && failure?.type === 4 /* ErrorTypes.NAVIGATION_ABORTED */) {
return return
} }

View File

@ -14,6 +14,7 @@ import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders
import { clearNuxtState, useState } from '#app/composables/state' import { clearNuxtState, useState } from '#app/composables/state'
import { useRequestURL } from '#app/composables/url' import { useRequestURL } from '#app/composables/url'
import { getAppManifest, getRouteRules } from '#app/composables/manifest' import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useLoadingIndicator } from '#app/composables/loading-indicator'
vi.mock('#app/compat/idle-callback', () => ({ vi.mock('#app/compat/idle-callback', () => ({
requestIdleCallback: (cb: Function) => cb() 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', () => { describe.skipIf(process.env.TEST_MANIFEST === 'manifest-off')('app manifests', () => {
it('getAppManifest', async () => { it('getAppManifest', async () => {
const manifest = await getAppManifest() const manifest = await getAppManifest()