diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 44fe596f17..c84a6c74ed 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -146,6 +146,7 @@ const APP_LEFT_NAV_WIDTH = '4rem' const APP_SIDEBAR_WIDTH = 300 const INTERCOM_BUBBLE_DEFAULT_PADDING = 20 const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime() +const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000 const credentials = ref() const sidebarToggled = ref(true) const unsubscribeSidebarToggle = themeStore.$subscribe(() => { @@ -155,6 +156,22 @@ const forceSidebar = computed( () => route.path.startsWith('/browse') || route.path.startsWith('/project'), ) const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value) +const keepAliveRouteComponents = computed(() => [ + ...new Set( + router + .getRoutes() + .map((route) => route.meta.keepAliveComponent) + .filter((name) => typeof name === 'string'), + ), +]) + +function getRouteViewKey(viewRoute) { + const keepAliveKey = viewRoute.meta.keepAliveKey + if (typeof keepAliveKey === 'function') return keepAliveKey(viewRoute) + if (typeof keepAliveKey === 'string') return keepAliveKey + return undefined +} + const hostingRouteActive = computed(() => route.path.startsWith('/hosting')) const prideFundraiserEnabled = computed( () => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, @@ -1673,11 +1690,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > {{ formatMessage(messages.authUnreachableBody) }} - + diff --git a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts index 90bc253cd7..8df78cbc53 100644 --- a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts +++ b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts @@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue' import { onUnmounted, ref, shallowRef } from 'vue' import type { Router } from 'vue-router' +import { + fetchCachedServerStatus, + getFreshCachedServerStatus, +} from '@/composables/instances/use-server-status-query' import { process_listener } from '@/helpers/events' import { kill, list as listInstances } from '@/helpers/instance' import { get_by_instance_id } from '@/helpers/process' import type { GameInstance } from '@/helpers/types' -import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds' +import { add_server_to_instance, getServerAddress } from '@/helpers/worlds' interface BrowseServerInstance { + id: string name: string path: string } @@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) { const queryClient = useQueryClient() const debugLog = useDebugLogger('BrowseServer') const serverPings = shallowRef>({}) - const serverPingCache = new Map() - const pendingServerPings = new Map>() const runningServerProjects = ref>({}) const lastServerHits = shallowRef([]) const contextMenuRef = ref(null) - let serverPingCacheActive = true + let serverPingsActive = true let unlistenProcesses: (() => void) | null = null async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { @@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) { }) const nextPings = { ...serverPings.value } for (const { hit, address } of pingsToFetch) { - if (serverPingCache.has(address)) { - nextPings[hit.project_id] = serverPingCache.get(address) + const cachedStatus = getFreshCachedServerStatus(queryClient, address) + if (cachedStatus) { + nextPings[hit.project_id] = cachedStatus.ping } } serverPings.value = nextPings await Promise.all( pingsToFetch.map(async ({ hit, address }) => { - if (serverPingCache.has(address)) return + if (getFreshCachedServerStatus(queryClient, address)) return - let pending = pendingServerPings.get(address) - if (!pending) { - pending = getServerLatency(address) - .then((latency) => { - if (serverPingCacheActive) serverPingCache.set(address, latency) - return latency - }) - .catch((error) => { - console.error(`Failed to ping server ${address}:`, error) - if (serverPingCacheActive) serverPingCache.set(address, undefined) - return undefined - }) - .finally(() => { - pendingServerPings.delete(address) - }) - pendingServerPings.set(address, pending) + try { + const status = await fetchCachedServerStatus(queryClient, address) + if (!serverPingsActive) return + serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping } + } catch (error) { + console.error(`Failed to ping server ${address}:`, error) + if (!serverPingsActive) return + serverPings.value = { ...serverPings.value, [hit.project_id]: undefined } } - - const latency = await pending - if (!serverPingCacheActive) return - serverPings.value = { ...serverPings.value, [hit.project_id]: latency } }), ) } @@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) { .catch(options.handleError) onUnmounted(() => { - serverPingCacheActive = false + serverPingsActive = false unlistenProcesses?.() - serverPingCache.clear() - pendingServerPings.clear() }) return { diff --git a/apps/app-frontend/src/composables/instances/use-server-status-query.ts b/apps/app-frontend/src/composables/instances/use-server-status-query.ts new file mode 100644 index 0000000000..a13387ef61 --- /dev/null +++ b/apps/app-frontend/src/composables/instances/use-server-status-query.ts @@ -0,0 +1,50 @@ +import type { QueryClient } from '@tanstack/vue-query' + +import { + get_server_status, + normalizeServerAddress, + type ProtocolVersion, + type ServerStatus, +} from '@/helpers/worlds' + +export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000 + +function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) { + if (!protocolVersion) return 'default' + return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}` +} + +export function getServerStatusQueryKey( + address: string, + protocolVersion: ProtocolVersion | null = null, +) { + return [ + 'minecraft-server-status', + normalizeServerAddress(address) || address.trim().toLowerCase(), + getProtocolVersionKey(protocolVersion), + ] as const +} + +export function getFreshCachedServerStatus( + queryClient: QueryClient, + address: string, + protocolVersion: ProtocolVersion | null = null, +) { + const queryKey = getServerStatusQueryKey(address, protocolVersion) + const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0 + if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined + return queryClient.getQueryData(queryKey) +} + +export async function fetchCachedServerStatus( + queryClient: QueryClient, + address: string, + protocolVersion: ProtocolVersion | null = null, +) { + return await queryClient.fetchQuery({ + queryKey: getServerStatusQueryKey(address, protocolVersion), + queryFn: () => get_server_status(address, protocolVersion), + staleTime: SERVER_STATUS_CACHE_MS, + gcTime: SERVER_STATUS_CACHE_MS, + }) +} diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index feb1e3969e..da28b6121a 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -25,7 +25,6 @@ import { requestInstall, SelectedProjectsFloatingBar, useBrowseSearch, - useDebugLogger, useStickyObserver, useVIntl, } from '@modrinth/ui' @@ -61,19 +60,26 @@ import { import { useBreadcrumbs } from '@/store/breadcrumbs' import { useTheming } from '@/store/state' +defineOptions({ + name: 'Browse', +}) + const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = injectServerInstall() const { install: installVersion } = injectContentInstall() const queryClient = useQueryClient() -const debugLog = useDebugLogger('Browse') const router = useRouter() const route = useRoute() const themeStore = useTheming() +const browseRouteActive = computed(() => route.path.startsWith('/browse/')) const serverSetupModalRef = ref | null>(null) -const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) +const serverInstallContent = createServerInstallContent({ + serverSetupModalRef, + isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'), +}) provideServerInstallContent(serverInstallContent) const { serverIdQuery, @@ -110,8 +116,6 @@ const { handleServerModpackFlowCreate, markServerProjectInstalled, } = serverInstallContent - -debugLog('fetching tags (categories, loaders, gameVersions)') const [categories, loaders, availableGameVersions] = await Promise.all([ get_categories() .catch(handleError) @@ -131,6 +135,7 @@ const tags: Ref = computed(() => ({ })) type Instance = { + id: string game_version: string loader: string path: string @@ -198,7 +203,6 @@ async function refreshInstalledProjectIds() { const serverProjectIds = worlds .filter((w) => w.type === 'server' && 'project_id' in w && w.project_id) .map((w) => (w as { project_id: string }).project_id) - debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length }) installedProjectIds.value = serverProjectIds return } @@ -206,45 +210,29 @@ async function refreshInstalledProjectIds() { const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError) if (!ids) return - debugLog('installedProjectIds loaded', { count: ids.length }) installedProjectIds.value = ids } async function initInstanceContext() { - debugLog('initInstanceContext', { - queryI: route.query.i, - queryAi: route.query.ai, - querySid: route.query.sid, - queryWid: route.query.wid, - queryFrom: route.query.from, - }) await initServerContext() if (route.query.i) { instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null - debugLog('instance loaded', { - name: instance.value?.name, - loader: instance.value?.loader, - gameVersion: instance.value?.game_version, - }) await refreshInstalledProjectIds() if (instance.value?.link?.project_id) { - debugLog('checking linked project for server status', instance.value.link.project_id) const projectV3 = await get_project_v3( instance.value.link.project_id, 'must_revalidate', ).catch(handleError) if (projectV3?.minecraft_server != null) { - debugLog('instance is a server instance') isServerInstance.value = true } } } if (route.query.ai && !(route.params.projectType === 'modpack')) { - debugLog('setting instanceHideInstalled from query', route.query.ai) instanceHideInstalled.value = route.query.ai === 'true' } } @@ -295,7 +283,7 @@ function syncHiddenServerContentProjectIds() { watch( serverContentProjectIds, () => { - if (!hiddenServerContentProjectIdsInitialized.value) { + if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) { syncHiddenServerContentProjectIds() } }, @@ -368,11 +356,9 @@ const { const offline = ref(!navigator.onLine) window.addEventListener('offline', () => { - debugLog('went offline') offline.value = true }) window.addEventListener('online', () => { - debugLog('went online') offline.value = false }) @@ -480,7 +466,6 @@ const projectType = ref(route.params.projectType as ProjectType) function resetInstanceContext() { if (!instance.value) return - debugLog('instance context removed, resetting') instance.value = null installedProjectIds.value = null instanceHideInstalled.value = false @@ -495,6 +480,9 @@ function resetInstanceContext() { watch( () => route.params.projectType as ProjectType, async (newType) => { + if (!browseRouteActive.value) { + return + } if (isSetupServerContext.value) { enforceSetupModpackRoute(newType) if (newType !== 'modpack') return @@ -502,7 +490,6 @@ watch( if (!newType || newType === projectType.value) return - debugLog('projectType route param changed', { from: projectType.value, to: newType }) projectType.value = newType }, ) @@ -616,6 +603,7 @@ const installContext = computed(() => { } return null }) + const stickyInstallHeaderRef = ref(null) const { isStuck: isInstallHeaderStuck } = useStickyObserver( stickyInstallHeaderRef, @@ -718,11 +706,10 @@ function getCardActions( installed?: boolean installing?: boolean } - const isInstalled = - projectResult.installed || - allInstalledIds.value.has(projectResult.project_id || '') || - serverContentProjectIds.value.has(projectResult.project_id || '') || - serverContextServerData.value?.upstream?.project_id === projectResult.project_id + const isInstalled = isServerContext.value + ? serverContentProjectIds.value.has(projectResult.project_id || '') || + serverContextServerData.value?.upstream?.project_id === projectResult.project_id + : projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '') const isInstalling = installingProjectIds.value.has(projectResult.project_id) if ( @@ -886,7 +873,6 @@ function onSearchResultsInstalled(ids: string[]) { } async function search(requestParams: string) { - debugLog('searching v3', requestParams) const isServer = projectType.value === 'server' const rawResults = await queryClient.fetchQuery({ @@ -968,6 +954,7 @@ const lockedFilterMessages = computed(() => ({ const searchState = useBrowseSearch({ projectType, tags, + active: browseRouteActive, providedFilters: combinedProvidedFilters, search, persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'], @@ -1043,7 +1030,12 @@ onUnmounted(() => { }) function getProjectBrowseQuery() { - if (!installContext.value) return undefined + if (!browseRouteActive.value) { + return undefined + } + if (!installContext.value) { + return undefined + } return { ...route.query, b: route.fullPath, @@ -1110,10 +1102,10 @@ provideBrowseManager({
- +
@@ -1145,7 +1137,7 @@ provideBrowseManager({ @create="handleServerModpackFlowCreate" /> - + diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 78c67a28a2..7b0534caed 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -127,6 +127,7 @@ import { ExternalIcon, EyeIcon, FolderOpenIcon, + TagCategoryGamepad2Icon as Gamepad2Icon, GlobeIcon, HashIcon, MoreVerticalIcon, @@ -135,7 +136,6 @@ import { PlusIcon, SettingsIcon, StopCircleIcon, - TagCategoryGamepad2Icon as Gamepad2Icon, TerminalSquareIcon, TimerIcon, UpdatedIcon, @@ -145,9 +145,9 @@ import { Avatar, formatLoaderLabel, injectNotificationManager, - LoaderIcon as ServerLoaderIcon, NavTabs, PageHeader, + LoaderIcon as ServerLoaderIcon, ServerOnlinePlayers, ServerPing, ServerRecentPlays, @@ -161,12 +161,16 @@ import dayjs from 'dayjs' import duration from 'dayjs/plugin/duration' import relativeTime from 'dayjs/plugin/relativeTime' import { computed, onUnmounted, ref, shallowRef, watch } from 'vue' -import { useRoute, useRouter } from 'vue-router' +import { onBeforeRouteUpdate, useRoute, useRouter, type LocationQuery } from 'vue-router' import ContextMenu from '@/components/ui/ContextMenu.vue' import ExportModal from '@/components/ui/ExportModal.vue' import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' +import { + fetchCachedServerStatus, + getFreshCachedServerStatus, +} from '@/composables/instances/use-server-status-query' import { useInstanceConsole } from '@/composables/useInstanceConsole' import { trackEvent } from '@/helpers/analytics' import { get_project_v3 } from '@/helpers/cache.js' @@ -177,7 +181,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins import { get_by_instance_id } from '@/helpers/process' import type { GameInstance } from '@/helpers/types' import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js' -import { get_server_status, refreshWorlds } from '@/helpers/worlds' +import { refreshWorlds, type ServerStatus } from '@/helpers/worlds' import { injectServerInstall } from '@/providers/server-install' import { handleSevereError } from '@/store/error.js' import { useBreadcrumbs, useTheming } from '@/store/state' @@ -222,13 +226,16 @@ const selected = ref([]) const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server) const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data) -const statusOnline = computed(() => !!javaServerPingData.value) +const liveServerStatusOnline = ref(false) +const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value) const recentPlays = computed( () => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined, ) const playersOnline = ref(undefined) const ping = ref(undefined) const loadingServerPing = ref(false) +const activeInstanceId = ref() +let fetchInstanceRequestId = 0 watch( () => router.currentRoute.value, @@ -240,24 +247,68 @@ watch( { immediate: true }, ) -function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) { +type InstanceRouteContext = { + name: unknown + path: string + query: LocationQuery +} + +type InstancePageData = { + instanceId: string + instance?: GameInstance + linkedProjectV3?: Labrinth.Projects.v3.Project + isServerInstance: boolean + preloadedContent: InstanceContentData | null +} + +function applyServerStatus(status: ServerStatus) { + playersOnline.value = status.players?.online + ping.value = status.ping + liveServerStatusOnline.value = true + loadingServerPing.value = true +} + +function isContentSubpageRoute(routeName: unknown = displayedInstanceRoute.value.name) { return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName) } -async function fetchInstance() { - isServerInstance.value = false - linkedProjectV3.value = undefined - preloadedContent.value = null +function resetServerStatus() { ping.value = undefined playersOnline.value = undefined + liveServerStatusOnline.value = false loadingServerPing.value = false +} - const nextInstance = await get(route.params.id as string).catch(handleError) +function isCurrentInstanceRequest(requestId: number, instanceId: string) { + return ( + requestId === fetchInstanceRequestId && + route.path.startsWith('/instance') && + route.params.id === instanceId + ) +} + +function setInstanceBreadcrumbs(nextInstance: GameInstance, routeContext: InstanceRouteContext) { + breadcrumbs.setName( + 'Instance', + nextInstance.name.length > 40 ? nextInstance.name.substring(0, 40) + '...' : nextInstance.name, + ) + breadcrumbs.setContext({ + name: nextInstance.name, + link: routeContext.path, + query: routeContext.query, + }) +} + +async function loadInstancePageData( + instanceId: string, + routeName: unknown = displayedInstanceRoute.value.name, +): Promise { + const nextInstance = await get(instanceId).catch(handleError) let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined let nextIsServerInstance = false const contentPreloadPromise = - nextInstance && isContentSubpageRoute() + nextInstance && isContentSubpageRoute(routeName) ? loadInstanceContentData(nextInstance.id, undefined, handleError) : Promise.resolve(null) @@ -275,59 +326,113 @@ async function fetchInstance() { const nextPreloadedContent = await contentPreloadPromise - instance.value = nextInstance ?? undefined - linkedProjectV3.value = nextLinkedProjectV3 - isServerInstance.value = nextIsServerInstance - preloadedContent.value = nextPreloadedContent + return { + instanceId, + instance: nextInstance ?? undefined, + linkedProjectV3: nextLinkedProjectV3, + isServerInstance: nextIsServerInstance, + preloadedContent: nextPreloadedContent, + } +} - fetchDeferredData() +function applyInstancePageData(data: InstancePageData, routeContext: InstanceRouteContext) { + activeInstanceId.value = data.instanceId + resetServerStatus() + playing.value = false - if (nextInstance) { + instance.value = data.instance + linkedProjectV3.value = data.linkedProjectV3 + isServerInstance.value = data.isServerInstance + preloadedContent.value = data.preloadedContent + + if (data.instance) { + setInstanceBreadcrumbs(data.instance, routeContext) + } + + fetchDeferredData(data.instanceId) + + if (data.instance) { queryClient.prefetchQuery({ - queryKey: ['worlds', nextInstance.id], - queryFn: () => refreshWorlds(nextInstance.id), + queryKey: ['worlds', data.instance.id], + queryFn: () => refreshWorlds(data.instance!.id), staleTime: 30_000, }) } } -function fetchDeferredData() { +async function fetchInstance(instanceId = route.params.id as string) { + const requestId = ++fetchInstanceRequestId + const data = await loadInstancePageData(instanceId) + if (!isCurrentInstanceRequest(requestId, instanceId)) return + + applyInstancePageData(data, { + name: route.name, + path: route.path, + query: route.query, + }) +} + +function fetchDeferredData(instanceId: string) { const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address if (isServerInstance.value && serverAddress) { - get_server_status(serverAddress) + const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress) + if (cachedStatus) { + applyServerStatus(cachedStatus) + } else { + playersOnline.value = undefined + ping.value = undefined + loadingServerPing.value = false + } + + fetchCachedServerStatus(queryClient, serverAddress) .then((status) => { - playersOnline.value = status.players?.online - ping.value = status.ping + if ( + activeInstanceId.value !== instanceId || + linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress + ) + return + applyServerStatus(status) }) .catch((error) => { console.error(`Failed to fetch server status for ${serverAddress}:`, error) }) .finally(() => { + if (activeInstanceId.value !== instanceId) return loadingServerPing.value = true }) } else { loadingServerPing.value = true } - updatePlayState() + updatePlayState(instanceId) } -async function updatePlayState() { - if (!route.params.id) return - const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError) +async function updatePlayState(instanceId = route.params.id as string) { + if (!instanceId) return + const runningProcesses = await get_by_instance_id(instanceId).catch(handleError) + if (activeInstanceId.value !== instanceId) return playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0 } -await fetchInstance() -watch( - () => route.params.id, - async () => { - if (route.params.id && route.path.startsWith('/instance')) { - await fetchInstance() - } - }, -) +await fetchInstance(route.params.id as string) + +onBeforeRouteUpdate(async (to) => { + if (!to.path.startsWith('/instance')) return + const instanceId = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id + if (typeof instanceId !== 'string') return false + + const requestId = ++fetchInstanceRequestId + const data = await loadInstancePageData(instanceId, to.name) + if (requestId !== fetchInstanceRequestId) return false + + displayedInstanceRoute.value = to + applyInstancePageData(data, { + name: to.name, + path: to.path, + query: to.query, + }) +}) const basePath = computed( () => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`, @@ -372,20 +477,6 @@ const tabs = computed(() => [ }, ]) -if (instance.value) { - breadcrumbs.setName( - 'Instance', - instance.value.name.length > 40 - ? instance.value.name.substring(0, 40) + '...' - : instance.value.name, - ) - breadcrumbs.setContext({ - name: instance.value.name, - link: displayedInstanceRoute.value.path, - query: displayedInstanceRoute.value.query, - }) -} - const options = ref | null>(null) const startInstance = async (context: string) => { diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index deb2e59305..10ecb04d84 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -47,9 +47,9 @@
- +