Files
Modrinth-plus/standards/frontend/CROSS_PLATFORM_PAGES.md
Calum H. 176d4301c3 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
2026-04-18 18:46:39 +00:00

8.0 KiB
Raw Blame History

Cross-Platform Pages

Pages that need to exist in both the Modrinth Website (apps/frontend) and the Modrinth App (apps/app-frontend) live in packages/ui/src/layouts/. There are two categories based on whether the page logic differs between platforms.

Shared Layouts (layouts/shared/)

For pages where the logic differs between the website and app (e.g. the app fetches data via Tauri invoke while the website uses api-client). Each shared layout is a self-contained module:

shared/content-tab/
├── layout.vue            # Main layout component
├── types.ts              # TypeScript types
├── components/           # Internal UI components
├── composables/          # Stateful logic (search, filtering, selection)
└── providers/            # DI context definitions

How it works

  1. A DI contract in providers/ defines all platform-specific operations as an interface.
  2. The layout component injects that context and handles all UI logic (search, filtering, selection, bulk operations, modals) without knowing the platform.
  3. Each platform provides its own implementation of the contract.

DI contract example

// shared/content-tab/providers/content-manager.ts
export interface ContentManagerContext {
	items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
	loading: Ref<boolean> | ComputedRef<boolean>

	// Platform-abstracted operations
	toggleEnabled: (item: ContentItem) => Promise<void>
	deleteItem: (item: ContentItem) => Promise<void>
	refresh: () => Promise<void>

	// Optional capabilities — not every platform supports everything
	hasUpdateSupport: boolean
	updateItem?: (id: string) => void
	bulkDeleteItems?: (items: ContentItem[]) => Promise<void>

	mapToTableItem: (item: ContentItem) => ContentCardTableItem
}

export const [injectContentManager, provideContentManager] =
	createContext<ContentManagerContext>('ContentPageLayout', 'contentManagerContext')

Platform implementations

Website — uses api-client and TanStack Query:

<!-- apps/frontend/src/pages/instance/content.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'

const { data: items } = useQuery({
	queryKey: ['content', instanceId],
	queryFn: () => client.content_v1.getAddons(instanceId),
})

provideContentManager({
	items: computed(() => items.value?.map(addonToContentItem) ?? []),
	deleteItem: async (item) => {
		await client.content_v1.deleteAddon(instanceId, item.id)
	},
	// ... rest of the contract
})
</script>

<template>
	<ContentPageLayout />
</template>

App — uses Tauri invoke:

<!-- apps/app-frontend/src/pages/instance/Mods.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'

const items = ref<ContentItem[]>([])
await invoke('get_instance_content', { instanceId }).then(/* map to ContentItem[] */)

provideContentManager({
	items,
	deleteItem: async (item) => {
		await invoke('delete_content', { instanceId, path: item.file_path })
	},
	// ... rest of the contract
})
</script>

<template>
	<ContentPageLayout />
</template>

Optional capabilities

The DI contract uses optional fields for features that not every platform supports. The layout checks for them before rendering the corresponding UI:

// Contract
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
shareItems?: (items: ContentItem[], format: string) => void

// Layout checks before showing UI
v-if="ctx.bulkUpdateItems && hasOutdatedProjects"

Props vs DI

Use When
DI Data depends on how it's fetched — API calls, file operations, navigation (per-platform)
Props Data is the same regardless of platform — configuration flags, display options

Wrapped Pages (layouts/wrapped/)

For pages where the logic is identical on both platforms — same API source, same data fetching, same state management. These are full page-level Vue components that directly implement routes:

wrapped/hosting/manage/
├── index.vue
├── content.vue
├── backups.vue
├── files.vue
└── [id]/onboarding.vue

Wrapped pages handle their own data fetching (typically via TanStack Query and api-client) and are consumed as simple component imports in both frontends:

<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
<script setup lang="ts">
import { ServersManageContentPage } from '@modrinth/ui'
</script>

<template>
	<ServersManageContentPage />
</template>

Platform route shells: prefetch with ensureQueryData

Wrapped layout: ReadyTransition and useReadyState

Many wrapped pages wrap the main UI in ReadyTransition with :pending driven by useReadyState on the primary TanStack query (true only on the first load while that query has no cached data yet—background refetches stay “ready”). That avoids flashing empty content before data exists.

<!-- Conceptual: inside packages/ui wrapped layout -->
<ReadyTransition :pending="readyPending">
	<SomePageLayout />
</ReadyTransition>
const primaryQuery = useQuery({ /* ... */ })
const readyPending = useReadyState(primaryQuery)
// or useReadyState({ isLoading, data }) when not using the full query object

Shell prefetch (below) warms the cache so that on navigation the query often already has data when the layout mounts; pending stays false and ReadyTransition can skip the enter animation on that fast path (see ReadyTransition docs and stories).

Rule: ensureQueryData in each platform route shell

When a wrapped layout uses that pattern, the thin platform page that imports the layout must prefetch the same primary query in <script setup> so the cache is warm before the layout mounts and ReadyTransition/useReadyState behave as intended.

Rule: For each primary useQuery in the wrapped layout that gates first paint (and thus useReadyState / ReadyTransition), the website and app route shells must call queryClient.ensureQueryData with the same queryKey, queryFn, and staleTime as that query. Wrap the call in try/catch and swallow errors so navigation does not fail during setup; the mounted layouts useQuery still runs and surfaces errors to the user.

import { injectModrinthClient, injectModrinthServerContext, ServersManageFilesPage } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'

const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()

try {
	await queryClient.ensureQueryData({
		queryKey: ['files', serverId, '/'],
		queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
		staleTime: 30_000,
	})
} catch {
	// Let the mounted layouts useQuery surface errors; do not fail route setup.
}

If a route parameter is required for the query (e.g. worldId), only call ensureQueryData when that value is present, matching the layouts enabled logic.

Duplicating the query definition in the shell is intentional until a shared query-options module exists; keep keys and fetchers aligned when editing the layout or the shell.

A wrapped page may still compose shared layouts internally — for example, the hosting content page uses the shared content-tab layout, providing its own ContentManagerContext with web API calls.

Composables

Reusable stateful logic lives in packages/ui/src/layouts/shared/*/composables/. These are consumed internally by the shared layout:

  • Search — Fuse.js fuzzy search over items
  • Filtering — Dynamic filter pills
  • Selection — Multi-select with bulk operation support
  • Bulk operations — Sequential execution with progress tracking