-
-
diff --git a/apps/app-frontend/src/components/ui/SplashScreen.vue b/apps/app-frontend/src/components/ui/SplashScreen.vue
index e4111171e..7b7e8d688 100644
--- a/apps/app-frontend/src/components/ui/SplashScreen.vue
+++ b/apps/app-frontend/src/components/ui/SplashScreen.vue
@@ -78,11 +78,11 @@
diff --git a/apps/app-frontend/src/pages/hosting/manage/Content.vue b/apps/app-frontend/src/pages/hosting/manage/Content.vue
index 02742246d..ab7d24245 100644
--- a/apps/app-frontend/src/pages/hosting/manage/Content.vue
+++ b/apps/app-frontend/src/pages/hosting/manage/Content.vue
@@ -1,5 +1,27 @@
diff --git a/apps/app-frontend/src/pages/hosting/manage/Files.vue b/apps/app-frontend/src/pages/hosting/manage/Files.vue
index 532c9b139..9bd07ee2e 100644
--- a/apps/app-frontend/src/pages/hosting/manage/Files.vue
+++ b/apps/app-frontend/src/pages/hosting/manage/Files.vue
@@ -1,5 +1,24 @@
diff --git a/apps/app-frontend/src/pages/hosting/manage/Index.vue b/apps/app-frontend/src/pages/hosting/manage/Index.vue
index fe4b1702f..4afb4e502 100644
--- a/apps/app-frontend/src/pages/hosting/manage/Index.vue
+++ b/apps/app-frontend/src/pages/hosting/manage/Index.vue
@@ -35,9 +35,6 @@
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
-
-
-
@@ -48,8 +45,8 @@
-
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index 4b57df3bf..4d399a03d 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -218,11 +218,7 @@
:key="instance.path"
>
-
+ stopInstance('InstanceSubpage')"
>
-
-
-
@@ -296,7 +289,6 @@ import {
ButtonStyled,
ContentPageHeader,
injectNotificationManager,
- LoadingIndicator,
NavTabs,
OverflowMenu,
ServerOnlinePlayers,
@@ -304,6 +296,7 @@ import {
ServerRecentPlays,
ServerRegion,
} from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
@@ -323,16 +316,17 @@ import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
-import { get_server_status } from '@/helpers/worlds'
+import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
-import { useBreadcrumbs, useLoading } from '@/store/state'
+import { useBreadcrumbs } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
+const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -392,6 +386,14 @@ async function fetchInstance() {
}
fetchDeferredData()
+
+ if (instance.value) {
+ queryClient.prefetchQuery({
+ queryKey: ['worlds', instance.value.path],
+ queryFn: () => refreshWorlds(instance.value!.path),
+ staleTime: 30_000,
+ })
+ }
}
function fetchDeferredData() {
@@ -471,8 +473,6 @@ if (instance.value) {
})
}
-const loadingBar = useLoading()
-
const options = ref | null>(null)
const startInstance = async (context: string) => {
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index e9c5e9413..bbf872dec 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -1,65 +1,67 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/providers/setup/loading-state.ts b/apps/app-frontend/src/providers/setup/loading-state.ts
new file mode 100644
index 000000000..21d3ed1fe
--- /dev/null
+++ b/apps/app-frontend/src/providers/setup/loading-state.ts
@@ -0,0 +1,17 @@
+import type { LoadingStateProvider } from '@modrinth/ui'
+import { createLoadingStateCore, provideLoadingState } from '@modrinth/ui'
+
+/**
+ * Source of truth for the desktop app's loading state.
+ *
+ * Owns the token-based ref-counter directly (no Pinia store). Consumers
+ * obtain the same reactive state via `injectLoadingState()` from `@modrinth/ui`.
+ *
+ * Returns the provider so the call site (App.vue) can also use it directly
+ * without a second injection round-trip.
+ */
+export function setupLoadingStateProvider(): LoadingStateProvider {
+ const provider = createLoadingStateCore({ barEnabled: false })
+ provideLoadingState(provider)
+ return provider
+}
diff --git a/apps/app-frontend/src/store/loading.js b/apps/app-frontend/src/store/loading.js
deleted file mode 100644
index 43f76c5f0..000000000
--- a/apps/app-frontend/src/store/loading.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { defineStore } from 'pinia'
-
-export const useLoading = defineStore('loadingStore', {
- state: () => ({
- loading: false,
- barEnabled: false,
- }),
- actions: {
- setEnabled(enabled) {
- this.barEnabled = enabled
- },
- startLoading() {
- this.loading = true
- },
- stopLoading() {
- this.loading = false
- },
- },
-})
diff --git a/apps/app-frontend/src/store/state.js b/apps/app-frontend/src/store/state.js
index 531de13ac..d25ee51b1 100644
--- a/apps/app-frontend/src/store/state.js
+++ b/apps/app-frontend/src/store/state.js
@@ -1,5 +1,4 @@
import { useBreadcrumbs } from './breadcrumbs'
-import { useLoading } from './loading'
import { useTheming } from './theme.ts'
-export { useBreadcrumbs, useLoading, useTheming }
+export { useBreadcrumbs, useTheming }
diff --git a/apps/frontend/src/app.vue b/apps/frontend/src/app.vue
index ebac15cd6..396d7a035 100644
--- a/apps/frontend/src/app.vue
+++ b/apps/frontend/src/app.vue
@@ -1,16 +1,15 @@
-
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/ReadyTransition.vue b/packages/ui/src/components/base/ReadyTransition.vue
new file mode 100644
index 000000000..170429d42
--- /dev/null
+++ b/packages/ui/src/components/base/ReadyTransition.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 2eb6f0d8e..b90e3d1a2 100644
--- a/packages/ui/src/components/base/index.ts
+++ b/packages/ui/src/components/base/index.ts
@@ -43,6 +43,7 @@ export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
export { default as JoinedButtons } from './JoinedButtons.vue'
+export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
@@ -62,6 +63,7 @@ export { default as ProgressBar } from './ProgressBar.vue'
export { default as ProgressSpinner } from './ProgressSpinner.vue'
export { default as RadialHeader } from './RadialHeader.vue'
export { default as RadioButtons } from './RadioButtons.vue'
+export { default as ReadyTransition } from './ReadyTransition.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts
index c5afc1d09..8a5c03e5d 100644
--- a/packages/ui/src/composables/index.ts
+++ b/packages/ui/src/composables/index.ts
@@ -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'
diff --git a/packages/ui/src/composables/use-loading-bar-token.ts b/packages/ui/src/composables/use-loading-bar-token.ts
new file mode 100644
index 000000000..8365c9d64
--- /dev/null
+++ b/packages/ui/src/composables/use-loading-bar-token.ts
@@ -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 `` (e.g. a page root with a complex v-if
+ * cascade where wrapping the template is awkward). ``
+ * remains the preferred API when it fits.
+ *
+ * Safe to call without a provider mounted; becomes a no-op.
+ */
+export function useLoadingBarToken(pending: Ref): 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)
+}
diff --git a/packages/ui/src/composables/use-loading-state-core.ts b/packages/ui/src/composables/use-loading-state-core.ts
new file mode 100644
index 000000000..641eddc88
--- /dev/null
+++ b/packages/ui/src/composables/use-loading-state-core.ts
@@ -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>(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,
+ }
+}
diff --git a/packages/ui/src/composables/use-ready-state.ts b/packages/ui/src/composables/use-ready-state.ts
new file mode 100644
index 000000000..8c2ca22fe
--- /dev/null
+++ b/packages/ui/src/composables/use-ready-state.ts
@@ -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 = Pick<
+ UseQueryReturnType,
+ '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 ``.
+ */
+export function useReadyState(
+ query: ReadyStateQuery,
+): Readonly> {
+ return computed(() => query.isLoading.value && query.data.value === undefined)
+}
diff --git a/packages/ui/src/layouts/shared/content-tab/layout.vue b/packages/ui/src/layouts/shared/content-tab/layout.vue
index d67b027ca..2b6a310d6 100644
--- a/packages/ui/src/layouts/shared/content-tab/layout.vue
+++ b/packages/ui/src/layouts/shared/content-tab/layout.vue
@@ -15,7 +15,6 @@ import {
RefreshCwIcon,
SearchIcon,
ShareIcon,
- SpinnerIcon,
TextCursorInputIcon,
TrashIcon,
UploadIcon,
@@ -504,304 +503,300 @@ const confirmUnlinkModal = ref>()