fix: invalidate tanstack caches on user auth (#5341)

* fix: invalidate tanstack caches on user auth

* refactor: clean up invalidate flow

* fix: lint
This commit is contained in:
Calum H.
2026-02-09 14:43:33 +00:00
committed by GitHub
parent e962521492
commit 90438a1ad5
16 changed files with 105 additions and 198 deletions

View File

@@ -1,6 +1,7 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
const STALE_TIME = 1000 * 60 * 5 // 5 minutes
export const STALE_TIME = 1000 * 60 * 5 // 5 minutes
export const STALE_TIME_LONG = 1000 * 60 * 10 // 10 minutes
export const projectQueryOptions = {
v2: (projectId: string, client: AbstractModrinthClient) => ({

View File

@@ -1,3 +1,5 @@
import { useAppQueryClient } from '@/composables/query-client'
export const useUser = async (force = false) => {
const user = useState('user', () => {})
@@ -158,5 +160,6 @@ export const logout = async () => {
await useAuth('none')
useCookie('auth-token').value = null
useAppQueryClient().clear()
stopLoading()
}

View File

@@ -1,4 +1,5 @@
import { useGeneratedState } from '~/composables/generated'
import { projectQueryOptions } from '~/composables/queries/project'
import { useAppQueryClient } from '~/composables/query-client'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
@@ -21,11 +22,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
try {
// Fetch v2 project for redirect check AND cache it for the page
// Using fetchQuery ensures the page's useQuery gets this cached result
const project = await queryClient.fetchQuery({
queryKey: ['project', 'v2', projectId],
queryFn: () => client.labrinth.projects_v2.get(projectId),
staleTime: 1000 * 60 * 5,
})
const project = await queryClient.fetchQuery(projectQueryOptions.v2(projectId, client))
// Let page handle 404
if (!project) return

View File

@@ -35,7 +35,7 @@
:is-settings="route.name.startsWith('type-id-settings')"
:set-processing="setProcessing"
:all-members="allMembers"
:update-members="refreshMembers"
:update-members="invalidateProject"
:auth="auth"
:tags="tags"
/>
@@ -747,7 +747,7 @@
:collapsed="collapsedChecklist"
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
:all-members="allMembers"
:update-members="updateMembers"
:update-members="invalidateProject"
:auth="auth"
:tags="tags"
/>
@@ -1008,6 +1008,7 @@ import ModerationChecklist from '~/components/ui/moderation/checklist/Moderation
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { useModerationStore } from '~/store/moderation.ts'
import { reportProject } from '~/utils/report-helpers.ts'
@@ -1472,14 +1473,10 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
// V2 Project - hits middleware cache (uses route param for lookup)
const {
data: projectRaw,
error: projectV2Error,
refetch: resetProjectV2,
} = useQuery({
const { data: projectRaw, error: projectV2Error } = useQuery({
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value),
staleTime: 1000 * 60 * 5,
staleTime: STALE_TIME,
})
// Handle project not found - use showError since watch runs outside Nuxt context
@@ -1522,26 +1519,18 @@ const project = computed(() => {
const projectId = computed(() => projectRaw.value?.id)
// V3 Project
const {
data: projectV3,
error: _projectV3Error,
refetch: resetProjectV3,
} = useQuery({
const { data: projectV3, error: _projectV3Error } = useQuery({
queryKey: computed(() => ['project', 'v3', projectId.value]),
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
staleTime: 1000 * 60 * 5,
staleTime: STALE_TIME,
enabled: computed(() => !!projectId.value),
})
// Members
const {
data: allMembersRaw,
error: _membersError,
refetch: _resetMembers,
} = useQuery({
const { data: allMembersRaw, error: _membersError } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']),
queryFn: () => client.labrinth.projects_v3.getMembers(projectId.value),
staleTime: 1000 * 60 * 5,
staleTime: STALE_TIME,
enabled: computed(() => !!projectId.value),
})
@@ -1556,25 +1545,26 @@ const allMembers = computed(() => {
})
// Dependencies - lazy loaded client-side only
const dependenciesEnabled = ref(false)
const {
data: dependenciesRaw,
error: _dependenciesError,
isFetching: dependenciesLoading,
refetch: refetchDependencies,
} = useQuery({
queryKey: computed(() => ['project', projectId.value, 'dependencies']),
queryFn: () => client.labrinth.projects_v2.getDependencies(projectId.value),
staleTime: 1000 * 60 * 10,
staleTime: STALE_TIME_LONG,
enabled: computed(() => !!projectId.value && dependenciesEnabled.value),
})
const dependencies = computed(() => dependenciesRaw.value ?? null)
// V3 Versions - lazy loaded client-side only
const versionsEnabled = ref(false)
const {
data: versionsV3,
error: _versionsV3Error,
isFetching: versionsV3Loading,
refetch: resetVersionsV3,
} = useQuery({
queryKey: computed(() => ['project', projectId.value, 'versions', 'v3']),
queryFn: () =>
@@ -1582,15 +1572,16 @@ const {
include_changelog: false,
apiVersion: 3,
}),
staleTime: 1000 * 60 * 10,
staleTime: STALE_TIME_LONG,
enabled: computed(() => !!projectId.value && versionsEnabled.value),
})
// Organization
// Only fetch organization if project belongs to one
const { data: organization, refetch: _resetOrganization } = useQuery({
const { data: organization } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'organization']),
queryFn: () => client.labrinth.projects_v3.getOrganization(projectId.value),
staleTime: 1000 * 60 * 5,
staleTime: STALE_TIME,
enabled: computed(() => !!projectId.value && !!projectRaw.value?.organization),
})
@@ -1616,17 +1607,13 @@ const versions = computed(() => {
const versionsLoading = computed(() => versionsV3Loading.value)
// Load versions on demand (client-side only)
async function loadVersions() {
// Skip if already loaded or loading
if (versionsV3.value || versionsV3Loading.value) return
await resetVersionsV3()
function loadVersions() {
versionsEnabled.value = true
}
// Load dependencies on demand (client-side only)
async function loadDependencies() {
// Skip if already loaded or loading
if (dependenciesRaw.value || dependenciesLoading.value) return
await refetchDependencies()
function loadDependencies() {
dependenciesEnabled.value = true
}
// Check if project has versions using the ID array from the V2 project
@@ -1654,25 +1641,13 @@ async function updateProjectRoute() {
}
}
async function resetProject() {
await invalidateProjectQueries(projectId.value)
await resetProjectV2()
await resetProjectV3()
}
async function resetVersions() {
await invalidateProjectQueries(projectId.value)
await resetVersionsV3()
}
// Helper to invalidate project queries after mutations settle
async function invalidateProjectQueries(projectId) {
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
if (routeProjectId.value !== projectId.value) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
}
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
await queryClient.invalidateQueries({ queryKey: ['project', projectId, 'versions', 'v3'] })
// Prefix match — invalidates members, versions, dependencies, organization
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] })
}
// Mutation for patching project data
@@ -1718,8 +1693,8 @@ const patchProjectMutation = useMutation({
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
onSettled: async () => {
await invalidateProject()
},
})
@@ -1763,8 +1738,8 @@ const patchStatusMutation = useMutation({
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
onSettled: async () => {
await invalidateProject()
},
})
@@ -1797,8 +1772,8 @@ const patchIconMutation = useMutation({
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
onSettled: async () => {
await invalidateProject()
},
})
@@ -1860,8 +1835,8 @@ const createGalleryItemMutation = useMutation({
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
onSettled: async () => {
await invalidateProject()
},
})
@@ -1922,8 +1897,8 @@ const editGalleryItemMutation = useMutation({
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
onSettled: async () => {
await invalidateProject()
},
})
@@ -1961,8 +1936,8 @@ const deleteGalleryItemMutation = useMutation({
})
},
onSettled: async (_data, _error, { projectId }) => {
await invalidateProjectQueries(projectId)
onSettled: async () => {
await invalidateProject()
},
})
@@ -2202,15 +2177,6 @@ async function deleteGalleryItem(imageUrl) {
})
}
async function refreshMembers() {
// Simply invalidate and refetch - the computed allMembers will auto-update
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'members'] })
}
async function refreshOrganization() {
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value, 'organization'] })
}
async function copyId() {
await navigator.clipboard.writeText(project.value.id)
}
@@ -2266,8 +2232,7 @@ async function deleteVersion(id) {
method: 'DELETE',
})
// Refetch versions to reflect deletion (versions is a computed ref)
await resetVersions()
await invalidateProject()
stopLoading()
}
@@ -2320,11 +2285,8 @@ provideProjectPageContext({
dependencies,
dependenciesLoading: computed(() => dependenciesLoading.value),
// Refresh functions (invalidate + refetch)
refreshProject: resetProject,
refreshVersions: resetVersions,
refreshMembers,
refreshOrganization,
// Invalidate all project queries (auto-refetches active ones)
invalidate: invalidateProject,
// Lazy loading
loadVersions,

View File

@@ -340,7 +340,6 @@ import {
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
injectProjectPageContext,
NewModal as Modal,
} from '@modrinth/ui'
@@ -352,8 +351,13 @@ import { isPermission } from '~/utils/permissions.ts'
const router = useRouter()
// Single DI injection
const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
const {
projectV2: project,
currentMember,
createGalleryItem: contextCreateGalleryItem,
editGalleryItem: contextEditGalleryItem,
deleteGalleryItem: contextDeleteGalleryItem,
} = injectProjectPageContext()
// Template refs
const modalEditItem = useTemplateRef('modal_edit_item')
@@ -486,37 +490,16 @@ async function createGalleryItem() {
shouldPreventActions.value = true
startLoading()
try {
let url = `project/${project.value.id}/gallery?ext=${
editFile.value
? editFile.value.type.split('/')[editFile.value.type.split('/').length - 1]
: null
}&featured=${editFeatured.value}`
if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
}
if (editDescription.value) {
url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
await useBaseFetch(url, {
method: 'POST',
body: editFile.value,
})
await refreshProject()
const success = await contextCreateGalleryItem(
editFile.value!,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
editOrder.value ? Number(editOrder.value) : undefined,
)
if (success) {
modalEditItem.value?.hide()
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
@@ -526,34 +509,18 @@ async function createGalleryItem() {
async function editGalleryItem() {
shouldPreventActions.value = true
startLoading()
try {
let url = `project/${project.value.id}/gallery?url=${encodeURIComponent(
project.value!.gallery![editIndex.value].url,
)}&featured=${editFeatured.value}`
if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
}
if (editDescription.value) {
url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
const imageUrl = project.value!.gallery![editIndex.value].url
const success = await contextEditGalleryItem(
imageUrl,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
editOrder.value ? Number(editOrder.value) : undefined,
)
await useBaseFetch(url, {
method: 'PATCH',
})
await refreshProject()
if (success) {
modalEditItem.value?.hide()
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
@@ -563,25 +530,8 @@ async function editGalleryItem() {
async function deleteGalleryImage() {
startLoading()
try {
await useBaseFetch(
`project/${project.value.id}/gallery?url=${encodeURIComponent(
project.value!.gallery![deleteIndex.value].url!,
)}`,
{
method: 'DELETE',
},
)
await refreshProject()
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: error.data?.description ?? String(err),
type: 'error',
})
}
const imageUrl = project.value!.gallery![deleteIndex.value].url!
await contextDeleteGalleryItem(imageUrl)
stopLoading()
}

View File

@@ -113,7 +113,7 @@ import {
} from '~/helpers/projects.js'
const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
const { projectV2: project, currentMember, invalidate } = injectProjectPageContext()
const auth = await useAuth()
@@ -134,7 +134,7 @@ async function setStatus(status) {
})
project.value.status = status
await refreshProject()
await invalidate()
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
} catch (err) {
addNotification({

View File

@@ -274,7 +274,7 @@ const {
currentMember,
patchProject,
patchIcon,
refreshProject,
invalidate,
} = injectProjectPageContext()
const flags = useFeatureFlags()
@@ -407,7 +407,7 @@ const deleteIcon = async () => {
await useBaseFetch(`project/${project.value.id}/icon`, {
method: 'DELETE',
})
await refreshProject()
await invalidate()
addNotification({
title: 'Project icon removed',
text: "Your project's icon has been removed.",

View File

@@ -562,9 +562,7 @@ const {
organization,
allMembers,
currentMember,
refreshProject,
refreshOrganization,
refreshMembers,
invalidate,
} = injectProjectPageContext()
const cosmetics = useCosmetics()
@@ -838,7 +836,7 @@ async function updateOrgMember(index) {
}
const updateMembers = async () => {
await Promise.all([refreshProject(), refreshOrganization(), refreshMembers()])
await invalidate()
}
</script>

View File

@@ -315,7 +315,7 @@ const {
projectV2: project,
currentMember,
versions,
refreshVersions,
invalidate,
loadVersions,
} = injectProjectPageContext()
@@ -387,7 +387,7 @@ async function deleteVersion() {
})
}
refreshVersions()
await invalidate()
selectedVersion.value = null
stopLoading()

View File

@@ -1,10 +1,6 @@
<template>
<div v-if="version" class="version-page">
<CreateProjectVersionModal
v-if="currentMember"
ref="createProjectVersionModal"
@save="handleVersionSaved"
/>
<CreateProjectVersionModal v-if="currentMember" ref="createProjectVersionModal" />
<ConfirmModal
v-if="currentMember"
ref="modal_confirm"
@@ -470,8 +466,7 @@ const {
loadVersions,
dependencies: contextDependencies,
loadDependencies,
refreshVersions,
refreshProject,
invalidate,
} = injectProjectPageContext()
// Load versions and dependencies in parallel
@@ -619,8 +614,8 @@ if (route.params.version === 'create') {
)) as any
if (versionV3) {
version.value = versionV3
// Refresh versions cache to include this version
await refreshVersions()
// Refresh cache to include this version
await invalidate()
}
} catch {
// API fetch failed - version truly doesn't exist, will 404 below
@@ -733,10 +728,6 @@ function handleOpenEditVersionModal(versionId: string, projectId: string, stageI
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
async function handleVersionSaved() {
router.go(0) // reload page for new data
}
async function _onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
@@ -1070,7 +1061,7 @@ async function createDataPackVersionHandler() {
}
async function resetProjectVersions() {
await Promise.all([refreshVersions(), refreshProject()])
await invalidate()
}
</script>

View File

@@ -313,7 +313,7 @@ const { addNotification } = injectNotificationManager()
const {
projectV2: project,
currentMember,
refreshVersions,
invalidate,
versions,
versionsLoading,
loadVersions,
@@ -379,7 +379,7 @@ async function deleteVersion() {
})
}
refreshVersions()
await invalidate()
selectedVersion.value = null
stopLoading()

View File

@@ -149,10 +149,12 @@ import {
IntlFormatted,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl, getLauncherRedirectUrl } from '@/composables/auth.js'
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -306,6 +308,7 @@ async function finishSignIn(token) {
if (token) {
await useAuth(token)
await useUser()
queryClient.clear()
}
if (route.query.redirect) {

View File

@@ -57,7 +57,9 @@ import {
normalizeChildren,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
@@ -96,6 +98,7 @@ const subscribe = ref(true)
onMounted(async () => {
await useAuth(route.query.authToken)
await useUser()
queryClient.clear()
})
async function continueSignUp() {

View File

@@ -166,7 +166,7 @@ export function createManageVersionContext(
): ManageVersionContextValue {
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { refreshVersions, projectV2 } = injectProjectPageContext()
const { invalidate, projectV2 } = injectProjectPageContext()
// State
const draftVersion = ref<Labrinth.Versions.v3.DraftVersion>(structuredClone(EMPTY_DRAFT_VERSION))
@@ -660,7 +660,7 @@ export function createManageVersionContext(
text: 'The version has been successfully added to your project.',
type: 'success',
})
await refreshVersions()
await invalidate()
onSave?.()
} catch (err: any) {
addNotification({
@@ -734,7 +734,7 @@ export function createManageVersionContext(
text: 'The version has been successfully saved to your project.',
type: 'success',
})
await refreshVersions()
await invalidate()
onSave?.()
} catch (err: any) {
addNotification({

View File

@@ -140,7 +140,7 @@ export class NuxtModrinthClient extends XHRUploadClient {
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt runtime
// @ts-expect-error - $fetch is provided by Nuxt
const response = await $fetch<T>(url, {
method: options.method ?? 'GET',
headers: options.headers,
@@ -148,6 +148,8 @@ export class NuxtModrinthClient extends XHRUploadClient {
params: options.params,
timeout: options.timeout,
signal: options.signal,
// @ts-expect-error - import.meta is provided by Nuxt
cache: import.meta.server ? undefined : 'no-store',
})
return response

View File

@@ -17,15 +17,12 @@ export interface ProjectPageContext {
dependencies: Ref<Labrinth.Projects.v2.DependencyInfo | null>
dependenciesLoading: Ref<boolean>
// Refresh functions (invalidate + refetch)
refreshProject: () => Promise<void>
refreshVersions: () => Promise<void>
refreshMembers: () => Promise<void>
refreshOrganization: () => Promise<void>
// Invalidate all project queries (auto-refetches active ones)
invalidate: () => Promise<void>
// Lazy loading
loadVersions: () => Promise<void>
loadDependencies: () => Promise<void>
loadVersions: () => void
loadDependencies: () => void
// Mutation functions
patchProject: (data: Record<string, unknown>, quiet?: boolean) => Promise<boolean>