From 176d4301c3bd1b721c884b3819fe44ab2b8f3918 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Sat, 18 Apr 2026 19:46:39 +0100 Subject: [PATCH] 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 --- .claude/skills/cross-platform-pages/SKILL.md | 1 + AGENTS.md | 1 - apps/app-frontend/src/App.vue | 106 +++- .../src/components/LoadingIndicatorBar.vue | 142 ----- .../src/components/ui/SplashScreen.vue | 14 +- .../src/pages/hosting/manage/Backups.vue | 23 +- .../src/pages/hosting/manage/Content.vue | 24 +- .../src/pages/hosting/manage/Files.vue | 21 +- .../src/pages/hosting/manage/Index.vue | 21 +- .../app-frontend/src/pages/instance/Files.vue | 9 +- .../app-frontend/src/pages/instance/Index.vue | 26 +- apps/app-frontend/src/pages/instance/Mods.vue | 125 ++-- .../src/pages/instance/Worlds.vue | 327 +++++----- .../src/providers/setup/loading-state.ts | 17 + apps/app-frontend/src/store/loading.js | 19 - apps/app-frontend/src/store/state.js | 3 +- apps/frontend/src/app.vue | 5 +- .../ui/modrinth-loading-indicator.ts | 142 ----- apps/frontend/src/error.vue | 6 +- .../src/pages/hosting/manage/[id].vue | 18 +- .../src/pages/hosting/manage/[id]/backups.vue | 23 +- .../src/pages/hosting/manage/[id]/content.vue | 24 +- .../src/pages/hosting/manage/[id]/files.vue | 21 +- apps/frontend/src/providers/setup.ts | 2 + .../src/providers/setup/loading-state.ts | 49 ++ packages/app-lib/src/api/worlds.rs | 84 +-- .../ui/src/components/base/LoadingBar.vue | 148 +++++ .../src/components/base/ReadyTransition.vue | 98 +++ packages/ui/src/components/base/index.ts | 2 + packages/ui/src/composables/index.ts | 3 + .../src/composables/use-loading-bar-token.ts | 43 ++ .../src/composables/use-loading-state-core.ts | 61 ++ .../ui/src/composables/use-ready-state.ts | 24 + .../src/layouts/shared/content-tab/layout.vue | 565 +++++++++--------- .../src/layouts/shared/files-tab/layout.vue | 334 +++++------ .../wrapped/hosting/manage/backups.vue | 208 +++---- .../wrapped/hosting/manage/content.vue | 94 +-- .../layouts/wrapped/hosting/manage/files.vue | 14 +- .../layouts/wrapped/hosting/manage/index.vue | 200 +++---- .../wrapped/hosting/manage/overview.vue | 1 + .../layouts/wrapped/hosting/manage/root.vue | 51 +- packages/ui/src/locales/en-US/index.json | 9 - packages/ui/src/providers/index.ts | 1 + packages/ui/src/providers/loading-state.ts | 27 + .../ui/src/stories/base/LoadingBar.stories.ts | 97 +++ .../stories/base/ReadyTransition.stories.ts | 151 +++++ standards/frontend/CROSS_PLATFORM_PAGES.md | 50 ++ 47 files changed, 2063 insertions(+), 1371 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 apps/app-frontend/src/components/LoadingIndicatorBar.vue create mode 100644 apps/app-frontend/src/providers/setup/loading-state.ts delete mode 100644 apps/app-frontend/src/store/loading.js delete mode 100644 apps/frontend/src/components/ui/modrinth-loading-indicator.ts create mode 100644 apps/frontend/src/providers/setup/loading-state.ts create mode 100644 packages/ui/src/components/base/LoadingBar.vue create mode 100644 packages/ui/src/components/base/ReadyTransition.vue create mode 100644 packages/ui/src/composables/use-loading-bar-token.ts create mode 100644 packages/ui/src/composables/use-loading-state-core.ts create mode 100644 packages/ui/src/composables/use-ready-state.ts create mode 100644 packages/ui/src/providers/loading-state.ts create mode 100644 packages/ui/src/stories/base/LoadingBar.stories.ts create mode 100644 packages/ui/src/stories/base/ReadyTransition.stories.ts diff --git a/.claude/skills/cross-platform-pages/SKILL.md b/.claude/skills/cross-platform-pages/SKILL.md index b3dfd9e8a..6558eed3c 100644 --- a/.claude/skills/cross-platform-pages/SKILL.md +++ b/.claude/skills/cross-platform-pages/SKILL.md @@ -22,4 +22,5 @@ Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standar - Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure. - Replace any platform-specific imports with shared utilities. - Import and render the wrapped page from both frontends as a simple component. + - If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`. 6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 59f6a8dbe..000000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -See [CLAUDE.md](./CLAUDE.md) for all project instructions and guidelines. diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 3b803b6da..0c7bf9fb0 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -38,6 +38,7 @@ import { CreationFlowModal, defineMessages, I18nDebugPanel, + LoadingBar, NewsArticleCard, NotificationPanel, OverflowMenu, @@ -52,7 +53,7 @@ import { useVIntl, } from '@modrinth/ui' import { formatBytes, renderString } from '@modrinth/utils' -import { useQuery } from '@tanstack/vue-query' +import { useQuery, useQueryClient } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' @@ -65,7 +66,6 @@ import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { RouterView, useRoute, useRouter } from 'vue-router' import ModrinthAppLogo from '@/assets/modrinth_app.svg?component' -import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue' import AccountsCard from '@/components/ui/AccountsCard.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import ErrorModal from '@/components/ui/ErrorModal.vue' @@ -113,8 +113,9 @@ import { import { createServerInstall, provideServerInstall } from '@/providers/server-install' import { setupProviders } from '@/providers/setup' import { setupAuthProvider } from '@/providers/setup/auth' +import { setupLoadingStateProvider } from '@/providers/setup/loading-state' import { useError } from '@/store/error.js' -import { useLoading, useTheming } from '@/store/state' +import { useTheming } from '@/store/state' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { get_available_capes, get_available_skins } from './helpers/skins' @@ -420,9 +421,11 @@ const handleClose = async () => { const router = useRouter() const route = useRoute() -const loading = useLoading() +const loading = setupLoadingStateProvider() loading.setEnabled(false) -loading.startLoading() +let initialLoadToken = loading.begin() +let routerToken = null +let suspenseToken = null let suspensePending = false @@ -435,7 +438,8 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({ router.beforeEach(() => { suspensePending = false - loading.startLoading() + if (routerToken) loading.end(routerToken) + routerToken = loading.begin() }) router.afterEach((to, from, failure) => { trackEvent('PageView', { @@ -445,11 +449,83 @@ router.afterEach((to, from, failure) => { }) setTimeout(() => { if (!suspensePending && stateInitialized.value) { - loading.stopLoading() + if (initialLoadToken) { + loading.end(initialLoadToken) + initialLoadToken = null + } + if (routerToken) { + loading.end(routerToken) + routerToken = null + } } }, 100) }) +function onSuspensePending() { + suspensePending = true + if (suspenseToken) loading.end(suspenseToken) + suspenseToken = loading.begin() +} + +function onSuspenseResolve() { + if (suspenseToken) { + loading.end(suspenseToken) + suspenseToken = null + } + if (routerToken) { + loading.end(routerToken) + routerToken = null + } +} + +const queryClient = useQueryClient() + +watch(stateInitialized, (ready) => { + if (ready) { + if (initialLoadToken) { + loading.end(initialLoadToken) + initialLoadToken = null + } + if (routerToken) { + loading.end(routerToken) + routerToken = null + } + + queryClient.prefetchQuery({ + queryKey: ['servers'], + queryFn: async () => { + const response = await tauriApiClient.archon.servers_v0.list({ limit: 100 }) + const hasMedalServers = response.servers.some((s) => s.is_medal) + if (hasMedalServers) { + const subscriptions = await tauriApiClient.labrinth.billing_internal.getSubscriptions() + for (const server of response.servers) { + if (server.is_medal) { + const sub = subscriptions.find((s) => s.metadata?.id === server.server_id) + if (sub) { + server.medal_expires = new Date( + new Date(sub.created).getTime() + 5 * 86400000, + ).toISOString() + } + } + } + } + return response + }, + staleTime: 30_000, + }) + queryClient.prefetchQuery({ + queryKey: ['billing', 'subscriptions'], + queryFn: () => tauriApiClient.labrinth.billing_internal.getSubscriptions(), + staleTime: 30_000, + }) + queryClient.prefetchQuery({ + queryKey: ['billing', 'payments'], + queryFn: () => tauriApiClient.labrinth.billing_internal.getPayments(), + staleTime: 30_000, + }) + } +}) + const error = useError() const errorModal = ref() const minecraftAuthErrorModal = ref() @@ -1236,7 +1312,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width))', }" > - +
diff --git a/apps/app-frontend/src/components/LoadingIndicatorBar.vue b/apps/app-frontend/src/components/LoadingIndicatorBar.vue deleted file mode 100644 index 7cbee4965..000000000 --- a/apps/app-frontend/src/components/LoadingIndicatorBar.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - - 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 @@