Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 28 commits into from Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d877acf
feat(nuxt): unify loading api behind hooks
huang-julien Oct 27, 2023
3ba5c6a
fix: trigger loading when pageKey changes
huang-julien Oct 28, 2023
71c4362
docs: update doc
huang-julien Oct 29, 2023
a0056da
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 29, 2023
a75dd98
test: add small runtime test
huang-julien Oct 29, 2023
38a17ad
Merge branch 'feat/unified-loading-pi' of https://github.com/nuxt/nux…
huang-julien Oct 29, 2023
b914cbf
docs: update doc
huang-julien Oct 29, 2023
75a906e
refactor: move out some functions
huang-julien Oct 30, 2023
00ee98b
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 30, 2023
2d25068
Merge remote-tracking branch 'origin/main' into feat/unified-loading-pi
huang-julien Nov 1, 2023
8641868
Merge branch 'feat/unified-loading-pi' of https://github.com/nuxt/nux…
huang-julien Nov 1, 2023
ae7593a
fix: fix merge issue
huang-julien Nov 1, 2023
297c3da
Merge remote-tracking branch 'origin/main' into feat/unified-loading-pi
danielroe Dec 14, 2023
76fc125
feat(nuxt): auto-import `useLoadingIndicator`
danielroe Dec 14, 2023
9ac8d68
fix: access normalised throttle
danielroe Dec 14, 2023
4d57ab1
refactor: use global singleton for loading indicator state
danielroe Dec 14, 2023
dd7f0c8
fix: await loading hooks
danielroe Dec 14, 2023
53f5cf1
fix: check for scope before registering cleanup
danielroe Dec 14, 2023
797574f
Revert "fix: await loading hooks"
danielroe Dec 14, 2023
b4ae961
docs: add links to hooks
danielroe Dec 14, 2023
006e843
Merge remote-tracking branch 'origin/main' into feat/unified-loading-pi
danielroe Dec 14, 2023
f07ae6a
docs: link to composable
danielroe Dec 14, 2023
f03fc53
fix: don't allow manipulating `isLoading`
danielroe Dec 15, 2023
d606f0d
perf: avoid duplicate object creation
danielroe Dec 15, 2023
ff6d74d
feat: add `set`
danielroe Dec 15, 2023
9fb520f
fix: await hooks again
danielroe Dec 15, 2023
f1dd32c
Merge remote-tracking branch 'origin/main' into feat/unified-loading-pi
danielroe Dec 15, 2023
dfd7eb6
Update docs/3.api/2.composables/use-loading-indicator.md
Atinux Dec 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/3.api/1.components/5.nuxt-loading-indicator.md
Expand Up @@ -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.
::
40 changes: 40 additions & 0 deletions 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 [`<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**: `number`
Atinux marked this conversation as resolved.
Show resolved Hide resolved
- **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.
2 changes: 2 additions & 0 deletions docs/3.api/6.advanced/1.hooks.md
Expand Up @@ -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)
Expand Down
115 changes: 6 additions & 109 deletions 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',
Expand All @@ -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',
Expand All @@ -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 changes: 134 additions & 0 deletions 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<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>
danielroe marked this conversation as resolved.
Show resolved Hide resolved
isLoading: Ref<boolean>
danielroe marked this conversation as resolved.
Show resolved Hide resolved
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
}
8 changes: 8 additions & 0 deletions packages/nuxt/src/app/nuxt.ts
Expand Up @@ -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'

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -112,6 +115,11 @@ interface _NuxtApp {
status: Ref<AsyncDataRequestStatus>
} | undefined>

/** @internal */
_loadingIndicator?: LoadingIndicator
/** @internal */
_loadingIndicatorDeps?: number

/** @internal */
_middleware: {
global: RouteMiddleware[]
Expand Down
4 changes: 4 additions & 0 deletions packages/nuxt/src/imports/presets.ts
Expand Up @@ -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'
Expand Down
12 changes: 10 additions & 2 deletions 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'
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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, {
Expand Down