refactor: removing useAsyncData for tanstack query (#5262)

* refactor: most places with useAsyncData replaced with tanstack query

* refactor report list and report view

* refactor organization page to use tanstack query

* fix types

* refactor collection page and include proper loading state

* fix followed projects proper loading state

* fix 404 handling

* fix organization loading and 404 states

* pnpm prepr

* refactor: remove useAsyncData on newsletter button

* refactor: remove useAsyncData on auth globals fetch

* refactor: settings/billing/index.vue to useQuery instead of useAsyncData

* refactor: user page to remove useAsyncData

* pnpm prepr

* fix reports pages

* fix notifications page

* fix billing page cannot read properties of null and prop warnings

* fix refresh causing 404 by removing useBaseFetch and use api-client

* fix stale data after removing organization from project

* pnpm prepr

* fix news erroring in build

* fix: project page loads header only after content

* fix: user page tanstack problems (start on migrating away from useBaseFetch)

* fix: start swapping useBaseFetch usages to api-client

* Revert "fix: start swapping useBaseFetch usages to api-client"

This reverts commit 3df3fab11d535159132b1288dd7cacc38282b553.

* fix: remove debug logging

* fix: lint

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-03-16 12:10:29 -07:00
committed by GitHub
parent d0c7575a23
commit 681ae5d1d8
53 changed files with 1686 additions and 1079 deletions

View File

@@ -1,19 +1,39 @@
<script setup lang="ts">
import { CheckIcon, MailIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { ref } from 'vue'
import { ButtonStyled, defineMessages, injectModrinthClient, useVIntl } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useBaseFetch } from '~/composables/fetch.js'
const { formatMessage } = useVIntl()
const auth = await useAuth()
const messages = defineMessages({
tooltipSubscribe: {
id: 'ui.newsletter-button.tooltip',
defaultMessage: 'Subscribe to the Modrinth newsletter',
},
subscribe: {
id: 'ui.newsletter-button.subscribe',
defaultMessage: 'Subscribe',
},
subscribed: {
id: 'ui.newsletter-button.subscribed',
defaultMessage: 'Subscribed!',
},
})
const auth = (await useAuth()) as unknown as {
value: { user: { id: string; username: string; email: string; created: string } }
}
const client = injectModrinthClient()
const queryClient = useQueryClient()
const showSubscriptionConfirmation = ref(false)
const showSubscribeButton = useAsyncData(
async () => {
const { data: showSubscribeButton, isSuccess } = useQuery({
queryKey: computed(() => ['newsletter', 'subscribed', auth.value?.user?.id]),
queryFn: async () => {
if (auth.value?.user) {
try {
const { subscribed } = await useBaseFetch('auth/email/subscribe', {
method: 'GET',
})
const { subscribed } = await client.labrinth.auth_internal.getNewsletterStatus()
return !subscribed
} catch {
return true
@@ -22,36 +42,31 @@ const showSubscribeButton = useAsyncData(
return false
}
},
{ watch: [auth], server: false },
)
enabled: computed(() => !!auth.value?.user),
})
async function subscribe() {
try {
await useBaseFetch('auth/email/subscribe', {
method: 'POST',
})
await client.labrinth.auth_internal.subscribeNewsletter()
showSubscriptionConfirmation.value = true
} catch {
// Ignored
} finally {
setTimeout(() => {
showSubscriptionConfirmation.value = false
showSubscribeButton.status.value = 'success'
showSubscribeButton.data.value = false
queryClient.setQueryData(['newsletter', 'subscribed', auth.value?.user?.id], false)
}, 2500)
}
}
</script>
<template>
<ButtonStyled
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
color="brand"
type="outlined"
>
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template>
<ButtonStyled v-if="isSuccess && showSubscribeButton" color="brand" type="outlined">
<button v-tooltip="formatMessage(messages.tooltipSubscribe)" @click="subscribe">
<template v-if="!showSubscriptionConfirmation">
<MailIcon /> {{ formatMessage(messages.subscribe) }}
</template>
<template v-else> <CheckIcon /> {{ formatMessage(messages.subscribed) }} </template>
</button>
</ButtonStyled>
</template>

View File

@@ -44,8 +44,11 @@
import { MessageIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { useBaseFetch } from '~/composables/fetch.js'
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -121,10 +124,10 @@ const apiEndpoint = computed(() => {
}
})
const { data: limits } = await useAsyncData<UserLimits | undefined>(
`limits-${props.type}`,
() => useBaseFetch(apiEndpoint.value, { apiVersion: 3 }) as Promise<UserLimits>,
)
const { data: limits } = useQuery({
queryKey: computed(() => ['limits', props.type]),
queryFn: () => useBaseFetch(apiEndpoint.value, { apiVersion: 3 }) as Promise<UserLimits>,
})
const typeName = computed<{ singular: string; plural: string }>(() => {
switch (props.type) {

View File

@@ -1,7 +1,8 @@
<template>
<div class="report">
<div v-if="report" class="report">
<div v-if="report.item_type === 'project'" class="item-info">
<nuxt-link
v-if="report.project"
:to="`/${$getProjectTypeForUrl(report.project.project_type, report.project.loaders)}/${
report.project.slug
}`"
@@ -38,27 +39,29 @@
</div>
</div>
<div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }">
<VersionIcon />
</div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
</nuxt-link>
<template v-if="report.version && report.project">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }">
<VersionIcon />
</div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
</nuxt-link>
</template>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
@@ -79,7 +82,7 @@
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
:auth="auth"
/>
<div class="reporter-info">
<div v-if="report.reporterUser" class="reporter-info">
<ReportIcon class="inline-svg" />
Reported by
<span v-if="auth.user.id === report.reporterUser.id">you</span>

View File

@@ -9,7 +9,7 @@
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
</section>
<section class="universal-card">
<section v-if="report && thread" class="universal-card">
<h2>Messages</h2>
<ConversationThread
:thread="thread"
@@ -21,9 +21,13 @@
</div>
</template>
<script setup>
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed } from 'vue'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { useBaseFetch } from '~/composables/fetch.js'
import { addReportMessage } from '~/helpers/threads.js'
const props = defineProps({
@@ -41,74 +45,90 @@ const props = defineProps({
},
})
const report = ref(null)
const queryClient = useQueryClient()
await fetchReport().then((result) => {
report.value = result
// Fetch raw report
const { data: rawReport } = useQuery({
queryKey: computed(() => ['report', props.reportId]),
queryFn: async () => {
const data = await useBaseFetch(`report/${props.reportId}`)
data.item_id = data.item_id.replace(/"/g, '')
return data
},
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`),
// Compute user IDs needed
const userIds = computed(() => {
if (!rawReport.value) return []
const ids = [rawReport.value.reporter]
if (rawReport.value.item_type === 'user') {
ids.push(rawReport.value.item_id)
}
return ids
})
// Fetch users
const { data: users } = useQuery({
queryKey: computed(() => ['users', userIds.value]),
queryFn: () => useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds.value))}`),
enabled: computed(() => userIds.value.length > 0),
})
// Version ID if applicable
const versionId = computed(() =>
rawReport.value?.item_type === 'version' ? rawReport.value.item_id : null,
)
// Fetch version
const { data: version } = useQuery({
queryKey: computed(() => ['version', versionId.value]),
queryFn: () => useBaseFetch(`version/${versionId.value}`),
enabled: computed(() => !!versionId.value),
})
// Project ID
const projectId = computed(() => {
if (version.value) return version.value.project_id
if (rawReport.value?.item_type === 'project') return rawReport.value.item_id
return null
})
// Fetch project
const { data: project } = useQuery({
queryKey: computed(() => ['project', projectId.value]),
queryFn: () => useBaseFetch(`project/${projectId.value}`),
enabled: computed(() => !!projectId.value),
})
// Assemble the full report object
const report = computed(() => {
if (!rawReport.value) return null
return {
...rawReport.value,
project: project.value ?? null,
version: version.value ?? null,
reporterUser: (users.value || []).find((user) => user.id === rawReport.value.reporter),
user:
rawReport.value.item_type === 'user'
? (users.value || []).find((user) => user.id === rawReport.value.item_id)
: undefined,
}
})
// Fetch thread
const { data: rawThread } = useQuery({
queryKey: computed(() => ['thread', report.value?.thread_id]),
queryFn: () => useBaseFetch(`thread/${report.value.thread_id}`),
enabled: computed(() => !!report.value?.thread_id),
})
const thread = computed(() =>
rawThread.value && report.value ? addReportMessage(rawThread.value, report.value) : null,
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
async function updateThread(newThread) {
rawThread.value = newThread
report.value = await fetchReport()
}
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`),
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
const userIds = []
userIds.push(rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
userIds.push(rawReport.value.item_id)
}
const versionId = rawReport.value.item_type === 'version' ? rawReport.value.item_id : null
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`),
)
users = usersVal.value
}
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`),
)
version = versionVal.value
}
const projectId = version
? version.project_id
: rawReport.value.item_type === 'project'
? rawReport.value.item_id
: null
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`),
)
project = projectVal.value
}
const reportData = rawReport.value
reportData.project = project
reportData.version = version
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
reportData.user = users.find((user) => user.id === rawReport.value.item_id)
}
return reportData
queryClient.setQueryData(['thread', report.value?.thread_id], newThread)
await queryClient.invalidateQueries({ queryKey: ['report', props.reportId] })
}
</script>
<style lang="scss" scoped>

View File

@@ -21,12 +21,15 @@
:auth="auth"
class="universal-card recessed"
/>
<p v-if="reports.length === 0">You don't have any active reports.</p>
<p v-if="filteredReports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { useBaseFetch } from '~/composables/fetch.js'
import { addReportMessage } from '~/helpers/threads.js'
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
@@ -43,77 +46,113 @@ const props = defineProps({
const viewMode = ref('open')
const reasonFilter = ref('All')
const reports = ref([])
const MAX_REPORTS = 1500
let { data: rawReports } = await useAsyncData('report', () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
)
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, '')
return report
const { data: rawReportsData } = useQuery({
queryKey: ['reports', MAX_REPORTS],
queryFn: () => useBaseFetch(`report?count=${MAX_REPORTS}`),
placeholderData: [],
})
const reporterUsers = rawReports.map((report) => report.reporter)
const reportedUsers = rawReports
.filter((report) => report.item_type === 'user')
.map((report) => report.item_id)
const versionReports = rawReports.filter((report) => report.item_type === 'version')
const versionIds = [...new Set(versionReports.map((report) => report.item_id))]
const userIds = [...new Set(reporterUsers.concat(reportedUsers))]
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
]
const reasons = ['All', ...new Set(rawReports.map((report) => report.report_type))]
const rawReports = computed(() =>
rawReportsData.value.map((report) => ({
...report,
item_id: report.item_id.replace(/"/g, ''),
})),
)
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
fetchSegmented(userIds, (ids) => `users?ids=${asEncodedJsonArray(ids)}`),
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
fetchSegmented(versionIds, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`),
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
fetchSegmented(threadIds, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
const reporterUsers = computed(() => rawReports.value.map((report) => report.reporter))
const reportedUsers = computed(() =>
rawReports.value.filter((report) => report.item_type === 'user').map((report) => report.item_id),
)
const versionReports = computed(() =>
rawReports.value.filter((report) => report.item_type === 'version'),
)
const versionIds = computed(() => [
...new Set(versionReports.value.map((report) => report.item_id)),
])
const userIds = computed(() => [...new Set(reporterUsers.value.concat(reportedUsers.value))])
const threadIds = computed(() => [
...new Set(
rawReports.value.filter((report) => report.thread_id).map((report) => report.thread_id),
),
])
const reasons = computed(() => [
'All',
...new Set(rawReports.value.map((report) => report.report_type)),
])
const reportedProjects = rawReports
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id)
const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
fetchSegmented(projectIds, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
)
reports.value = rawReports.map((report) => {
report.reporterUser = users.value.find((user) => user.id === report.reporter)
if (report.item_type === 'user') {
report.user = users.value.find((user) => user.id === report.item_id)
} else if (report.item_type === 'project') {
report.project = projects.value.find((project) => project.id === report.item_id)
} else if (report.item_type === 'version') {
report.version = versions.value.find((version) => version.id === report.item_id)
report.project = projects.value.find((project) => project.id === report.version.project_id)
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report,
)
}
report.open = true
return report
const { data: users } = useQuery({
queryKey: computed(() => ['users', userIds.value]),
queryFn: () => fetchSegmented(userIds.value, (ids) => `users?ids=${asEncodedJsonArray(ids)}`),
enabled: computed(() => userIds.value.length > 0),
placeholderData: [],
})
const { data: versions } = useQuery({
queryKey: computed(() => ['versions', versionIds.value]),
queryFn: () =>
fetchSegmented(versionIds.value, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`),
enabled: computed(() => versionIds.value.length > 0),
placeholderData: [],
})
const { data: threads } = useQuery({
queryKey: computed(() => ['threads', threadIds.value]),
queryFn: () => fetchSegmented(threadIds.value, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
enabled: computed(() => threadIds.value.length > 0),
placeholderData: [],
})
const reportedProjects = computed(() =>
rawReports.value
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id),
)
const versionProjects = computed(() => versions.value.map((version) => version.project_id))
const projectIds = computed(() => [
...new Set(reportedProjects.value.concat(versionProjects.value)),
])
const { data: projects } = useQuery({
queryKey: computed(() => ['projects', projectIds.value]),
queryFn: () =>
fetchSegmented(projectIds.value, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
enabled: computed(() => projectIds.value.length > 0),
placeholderData: [],
})
const userMap = computed(() => new Map(users.value.map((u) => [u.id, u])))
const versionMap = computed(() => new Map(versions.value.map((v) => [v.id, v])))
const projectMap = computed(() => new Map(projects.value.map((p) => [p.id, p])))
const threadMap = computed(() => new Map(threads.value.map((t) => [t.id, t])))
const reports = computed(() =>
rawReports.value.map((report) => {
const enrichedReport = { ...report }
enrichedReport.reporterUser = userMap.value.get(report.reporter)
if (report.item_type === 'user') {
enrichedReport.user = userMap.value.get(report.item_id)
} else if (report.item_type === 'project') {
enrichedReport.project = projectMap.value.get(report.item_id)
} else if (report.item_type === 'version') {
enrichedReport.version = versionMap.value.get(report.item_id)
enrichedReport.project = projectMap.value.get(enrichedReport.version?.project_id)
}
if (report.thread_id) {
const thread = threadMap.value.get(report.thread_id)
enrichedReport.thread = thread ? addReportMessage(thread, enrichedReport) : null
}
enrichedReport.open = true
return enrichedReport
}),
)
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(props.moderation || x.reporterUser?.id === props.auth.user.id) &&
(viewMode.value === 'open' ? x.open : !x.open) &&
(reasonFilter.value === 'All' || reasonFilter.value === x.report_type),
),