feat: shared loading state + cleanup loading state management (#5835)

* feat: implement shared loading bar component and polished loading states across the app

* feat: align loading states + ensureQueryData changes

* fix: lint + bugs

* fix: skeleton for manage servers page

* fix: merge conflict fix
This commit is contained in:
Calum H.
2026-04-18 19:46:39 +01:00
committed by GitHub
parent 3e32901737
commit 176d4301c3
47 changed files with 2063 additions and 1371 deletions

View File

@@ -13,6 +13,9 @@ export * from './server-console'
export * from './server-manage-core-runtime'
export * from './sticky-observer'
export * from './terminal'
export * from './use-loading-bar-token'
export * from './use-loading-state-core'
export * from './use-ready-state'
export * from './use-server-image'
export * from './use-server-project'
export * from './virtual-scroll'

View File

@@ -0,0 +1,43 @@
import type { Ref } from 'vue'
import { onBeforeUnmount, watch } from 'vue'
import { injectLoadingState } from '#ui/providers/loading-state'
/**
* Register a `LoadingBar` token for as long as `pending` is truthy.
*
* Use this when the component that owns the load is not the natural place
* to mount a `<ReadyTransition>` (e.g. a page root with a complex v-if
* cascade where wrapping the template is awkward). `<ReadyTransition>`
* remains the preferred API when it fits.
*
* Safe to call without a provider mounted; becomes a no-op.
*/
export function useLoadingBarToken(pending: Ref<boolean>): void {
const loadingState = injectLoadingState(null)
if (!loadingState) return
let token: symbol | null = null
function release() {
if (token) {
loadingState.end(token)
token = null
}
}
watch(
pending,
(now) => {
if (typeof window === 'undefined') return
if (now && !token) {
token = loadingState.begin()
} else if (!now) {
release()
}
},
{ immediate: true },
)
onBeforeUnmount(release)
}

View File

@@ -0,0 +1,61 @@
import { computed, ref, shallowRef } from 'vue'
import type { LoadingStateProvider } from '#ui/providers/loading-state'
export interface LoadingStateCoreOptions {
/** Initial value of the host kill-switch. Default: true. */
barEnabled?: boolean
}
/**
* Build a token-based `LoadingStateProvider` implementation.
*
* Multiple `ReadyTransition` instances (or any caller) can hold tokens at the
* same time; the bar stays visible while at least one is live. `end(token)`
* is idempotent so a stale token release after unmount is harmless.
*
* SSR safe: timers and DOM access are deferred to component code; this core
* is pure reactive state.
*/
export function createLoadingStateCore(opts: LoadingStateCoreOptions = {}): LoadingStateProvider {
const tokens = shallowRef<Set<symbol>>(new Set())
const barEnabled = ref(opts.barEnabled ?? true)
const pending = computed(() => tokens.value.size > 0)
function begin(): symbol {
const token = Symbol('loading-state-token')
const next = new Set(tokens.value)
next.add(token)
tokens.value = next
return token
}
function end(token: symbol): void {
if (!tokens.value.has(token)) return
const next = new Set(tokens.value)
next.delete(token)
tokens.value = next
}
function beginManual(durationMs = 500): void {
const token = begin()
if (typeof window === 'undefined') {
end(token)
return
}
window.setTimeout(() => end(token), durationMs)
}
function setEnabled(enabled: boolean): void {
barEnabled.value = enabled
}
return {
pending,
barEnabled,
begin,
end,
beginManual,
setEnabled,
}
}

View File

@@ -0,0 +1,24 @@
import type { DefaultError, UseQueryReturnType } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { computed } from 'vue'
/** Subset of {@link UseQueryReturnType} passed to {@link useReadyState}. */
export type ReadyStateQuery<TData, TError = DefaultError> = Pick<
UseQueryReturnType<TData, TError>,
'isLoading' | 'data'
>
/**
* Returns true while a query is loading for the FIRST time (no cached data yet).
*
* Excludes background refetches and refetch-on-window-focus by design — those
* have `isLoading === false` once data exists in the cache, so `ReadyTransition`
* stays open and the loading bar stays silent.
*
* Pair with `<ReadyTransition :pending="var which is useReadyState(query)" />`.
*/
export function useReadyState<TData, TError = DefaultError>(
query: ReadyStateQuery<TData, TError>,
): Readonly<Ref<boolean>> {
return computed(() => query.isLoading.value && query.data.value === undefined)
}