diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 37484e56b6c..48341d02f49 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -37,6 +37,7 @@ import { NewsArticleCard, NotificationPanel, PopupNotificationPanel, + provideLoadingState, provideModalBehavior, provideModrinthClient, provideNotificationManager, @@ -63,6 +64,7 @@ import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { RouterView, useRoute, useRouter } from 'vue-router' +import { useRouteLoading } from '@/app/runtime/use-route-loading' import AppTitleBar from '@/app/shell/title-bar/index.vue' import AccountsCard from '@/components/ui/AccountsCard.vue' import ErrorModal from '@/components/ui/ErrorModal.vue' @@ -184,7 +186,6 @@ import { createServerInstall, provideServerInstall } from '@/providers/server-in import { setupProviders } from '@/providers/setup' import { setupAppEventsProvider } from '@/providers/setup/app-events' import { setupAuthProvider } from '@/providers/setup/auth' -import { setupLoadingStateProvider } from '@/providers/setup/loading-state' import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts' import { createBreadcrumbManager, provideBreadcrumbManager } from '@/shared/breadcrumbs' import { appMessages } from '@/utils/app-messages' @@ -206,15 +207,6 @@ const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider() useInstanceMetadataRefresh(appEvents) const breadcrumbManager = createBreadcrumbManager() provideBreadcrumbManager(breadcrumbManager) -const canNavigateBack = ref(false) -const canNavigateForward = ref(false) - -function updateHistoryNavigationState() { - const historyState = window.history.state - canNavigateBack.value = historyState?.back != null - canNavigateForward.value = historyState?.forward != null -} - let fullscreenAdsWindowHold = false async function handleFullscreenChange() { @@ -234,8 +226,6 @@ async function handleFullscreenChange() { } } -updateHistoryNavigationState() - const APP_LEFT_NAV_WIDTH = '4rem' const APP_SIDEBAR_WIDTH = 300 const INTERCOM_BUBBLE_DEFAULT_PADDING = 20 @@ -894,6 +884,10 @@ async function setupApp() { } const stateFailed = ref(false) +const { loading, canNavigateBack, canNavigateForward, onSuspensePending, onSuspenseResolve } = + useRouteLoading({ stateInitialized, stateFailed }) +provideLoadingState(loading) + traceStartupStep('Initialize backend state', () => initialize_state(appEventChannel)) .then(() => { traceStartupStep('Initialize frontend state', setupApp).catch((err) => { @@ -913,21 +907,6 @@ const handleClose = async () => { await getCurrentWindow().close() } -const loading = setupLoadingStateProvider(() => ({ - stateInitialized: stateInitialized.value, - stateFailed: stateFailed.value, - initialStatePending: !!initialLoadToken, - navigationPending: !!routerToken, - routeSuspensePending: !!suspenseToken, - route: route.path, -})) -loading.setEnabled(false) -let initialLoadToken = loading.begin('Initial app state') -let routerToken = null -let suspenseToken = null - -let suspensePending = false - const sidebarOverlayScrollbarsOptions = Object.freeze({ overflow: { x: 'hidden', @@ -935,72 +914,10 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({ }, }) -router.beforeEach((to, from) => { - debugStartup('Route navigation started', { to: to.path, from: from.path }) - suspensePending = false - if (routerToken) loading.end(routerToken) - routerToken = loading.begin(`Route navigation: ${to.path}`) -}) -router.afterEach((to, from, failure) => { - debugStartup('Route navigation settled', { to: to.path, failed: !!failure }) - updateHistoryNavigationState() - trackEvent('PageView', { - path: to.path, - fromPath: from.path, - failed: !!failure, - }) - setTimeout(() => { - debugStartup('Route loading release check', { - route: to.path, - suspensePending, - stateInitialized: stateInitialized.value, - }) - if (!suspensePending && stateInitialized.value) { - if (initialLoadToken) { - loading.end(initialLoadToken) - initialLoadToken = null - } - if (routerToken) { - loading.end(routerToken) - routerToken = null - } - } - }, 100) -}) - -function onSuspensePending() { - debugStartup('Route Suspense pending', { route: route.path }) - suspensePending = true - if (suspenseToken) loading.end(suspenseToken) - suspenseToken = loading.begin(`Route Suspense: ${route.path}`) -} - -function onSuspenseResolve() { - debugStartup('Route Suspense resolved', { route: route.path }) - if (suspenseToken) { - loading.end(suspenseToken) - suspenseToken = null - } - if (routerToken) { - loading.end(routerToken) - routerToken = null - } -} - const queryClient = useQueryClient() watch(stateInitialized, (ready) => { - debugStartup('State readiness changed', { ready }) if (ready) { - if (initialLoadToken) { - loading.end(initialLoadToken) - initialLoadToken = null - } - if (routerToken) { - loading.end(routerToken) - routerToken = null - } - queryClient.prefetchQuery({ queryKey: ['servers'], queryFn: async () => { diff --git a/apps/app-frontend/src/providers/setup/loading-state.ts b/apps/app-frontend/src/app/runtime/use-app-loading-state.ts similarity index 84% rename from apps/app-frontend/src/providers/setup/loading-state.ts rename to apps/app-frontend/src/app/runtime/use-app-loading-state.ts index f4a3341f0ac..88a1cca2375 100644 --- a/apps/app-frontend/src/providers/setup/loading-state.ts +++ b/apps/app-frontend/src/app/runtime/use-app-loading-state.ts @@ -1,8 +1,4 @@ -import { - createLoadingStateCore, - type LoadingStateProvider, - provideLoadingState, -} from '@modrinth/ui' +import { createLoadingStateCore, type LoadingStateProvider } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' import { getCurrentInstance, onScopeDispose } from 'vue' @@ -12,21 +8,11 @@ type AppLoadingStateProvider = Omit & { begin(label?: string): symbol } -/** - * Source of truth for the desktop app's loading state. - * - * Owns the token-based ref-counter directly. 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( +export function useAppLoadingState( getContext: () => Record = () => ({}), ): AppLoadingStateProvider { const core = createLoadingStateCore({ barEnabled: false }) if (!import.meta.env.DEV) { - provideLoadingState(core) return core } const queryClient = useQueryClient() @@ -114,6 +100,5 @@ export function setupLoadingStateProvider( }, } onScopeDispose(stopReporting) - provideLoadingState(provider) return provider } diff --git a/apps/app-frontend/src/app/runtime/use-route-loading.ts b/apps/app-frontend/src/app/runtime/use-route-loading.ts new file mode 100644 index 00000000000..31bc802f8ab --- /dev/null +++ b/apps/app-frontend/src/app/runtime/use-route-loading.ts @@ -0,0 +1,120 @@ +import { onScopeDispose, type Ref, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +import { trackEvent } from '@/helpers/analytics' +import { debugStartup } from '@/helpers/startup-debug' + +import { useAppLoadingState } from './use-app-loading-state' + +type RouteLoadingOptions = { + stateInitialized: Readonly> + stateFailed: Readonly> +} + +export function useRouteLoading({ stateInitialized, stateFailed }: RouteLoadingOptions) { + const router = useRouter() + const route = useRoute() + const canNavigateBack = ref(false) + const canNavigateForward = ref(false) + let initialLoadToken: symbol | null = null + let routerToken: symbol | null = null + let suspenseToken: symbol | null = null + let suspensePending = false + const releaseTimers = new Set>() + + const loading = useAppLoadingState(() => ({ + stateInitialized: stateInitialized.value, + stateFailed: stateFailed.value, + initialStatePending: !!initialLoadToken, + navigationPending: !!routerToken, + routeSuspensePending: !!suspenseToken, + route: route.path, + })) + loading.setEnabled(false) + initialLoadToken = loading.begin('Initial app state') + + function updateHistoryNavigationState() { + const historyState = window.history.state + canNavigateBack.value = historyState?.back != null + canNavigateForward.value = historyState?.forward != null + } + + function releaseInitialAndNavigationTokens() { + if (initialLoadToken) { + loading.end(initialLoadToken) + initialLoadToken = null + } + if (routerToken) { + loading.end(routerToken) + routerToken = null + } + } + + updateHistoryNavigationState() + + const removeBeforeEach = router.beforeEach((to, from) => { + debugStartup('Route navigation started', { to: to.path, from: from.path }) + suspensePending = false + if (routerToken) loading.end(routerToken) + routerToken = loading.begin(`Route navigation: ${to.path}`) + }) + const removeAfterEach = router.afterEach((to, from, failure) => { + debugStartup('Route navigation settled', { to: to.path, failed: !!failure }) + updateHistoryNavigationState() + trackEvent('PageView', { + path: to.path, + fromPath: from.path, + failed: !!failure, + }) + const timer = setTimeout(() => { + releaseTimers.delete(timer) + debugStartup('Route loading release check', { + route: to.path, + suspensePending, + stateInitialized: stateInitialized.value, + }) + if (!suspensePending && stateInitialized.value) { + releaseInitialAndNavigationTokens() + } + }, 100) + releaseTimers.add(timer) + }) + + function onSuspensePending() { + debugStartup('Route Suspense pending', { route: route.path }) + suspensePending = true + if (suspenseToken) loading.end(suspenseToken) + suspenseToken = loading.begin(`Route Suspense: ${route.path}`) + } + + function onSuspenseResolve() { + debugStartup('Route Suspense resolved', { route: route.path }) + if (suspenseToken) { + loading.end(suspenseToken) + suspenseToken = null + } + if (routerToken) { + loading.end(routerToken) + routerToken = null + } + } + + watch(stateInitialized, (ready) => { + debugStartup('State readiness changed', { ready }) + if (ready) releaseInitialAndNavigationTokens() + }) + + onScopeDispose(() => { + removeBeforeEach() + removeAfterEach() + for (const timer of releaseTimers) clearTimeout(timer) + releaseTimers.clear() + releaseInitialAndNavigationTokens() + if (suspenseToken) { + loading.end(suspenseToken) + suspenseToken = null + } + }) + + return { loading, canNavigateBack, canNavigateForward, onSuspensePending, onSuspenseResolve } +}