fix: version tags toggling every supported version and freezing the page (#6967)

Fixes #6601.

Clicking any game version tag on the versions tab passed the release's
entire game_versions array to toggleFilters, so clicking 26.x (or even a
single version tag) selected every version the release supports. On
projects supporting many versions this also froze the page for seconds:
toggleFilters awaited a needlessly-async toggleFilter per version, which
let Vue flush a full re-render between every iteration (~90 flushes on
the reported project).

- formatVersionsForDisplay's grouping now also returns the member
  versions per displayed range (getVersionGroupsForDisplay), so version
  tags toggle exactly the versions they represent
- toggleFilters applies the whole batch synchronously in one update, and
  selects the missing versions unless all are already selected (instead
  of blindly inverting each one)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LarpinWBD
2026-08-04 21:12:57 +00:00
committed by GitHub
co-authored by Claude
parent bb2193b6f5
commit 3487523f74
3 changed files with 110 additions and 67 deletions
@@ -120,14 +120,17 @@
<template #cell-gameVersions="{ row: version }"> <template #cell-gameVersions="{ row: version }">
<div class="flex min-w-0 w-full max-w-[12rem] flex-wrap gap-1"> <div class="flex min-w-0 w-full max-w-[12rem] flex-wrap gap-1">
<TagItem <TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice(0, MAX_GAME_VERSION_TAGS)" v-for="gameVersionGroup in getDisplayGameVersions(version).slice(
:key="`version-tag-${gameVersion}`" 0,
v-tooltip="getFilterTooltip(gameVersion)" MAX_GAME_VERSION_TAGS,
)"
:key="`version-tag-${gameVersionGroup.label}`"
v-tooltip="getFilterTooltip(gameVersionGroup.label)"
data-no-row-click data-no-row-click
class="w-fit max-w-full truncate" class="w-fit max-w-full truncate"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)" :action="() => versionFilters?.toggleFilters('gameVersion', gameVersionGroup.versions)"
> >
<span class="min-w-0 truncate">{{ gameVersion }}</span> <span class="min-w-0 truncate">{{ gameVersionGroup.label }}</span>
</TagItem> </TagItem>
<Menu <Menu
v-if="getDisplayGameVersions(version).length > MAX_GAME_VERSION_TAGS" v-if="getDisplayGameVersions(version).length > MAX_GAME_VERSION_TAGS"
@@ -144,12 +147,16 @@
<template #popper> <template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1"> <div class="flex max-w-[20rem] flex-wrap gap-1">
<TagItem <TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice(MAX_GAME_VERSION_TAGS)" v-for="gameVersionGroup in getDisplayGameVersions(version).slice(
:key="`overflow-version-tag-${gameVersion}`" MAX_GAME_VERSION_TAGS,
)"
:key="`overflow-version-tag-${gameVersionGroup.label}`"
class="w-fit max-w-full truncate" class="w-fit max-w-full truncate"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)" :action="
() => versionFilters?.toggleFilters('gameVersion', gameVersionGroup.versions)
"
> >
<span class="min-w-0 truncate">{{ gameVersion }}</span> <span class="min-w-0 truncate">{{ gameVersionGroup.label }}</span>
</TagItem> </TagItem>
</div> </div>
</template> </template>
@@ -316,16 +323,18 @@
<div class="flex flex-col justify-center gap-3"> <div class="flex flex-col justify-center gap-3">
<div class="flex flex-row flex-wrap items-center gap-1.5"> <div class="flex flex-row flex-wrap items-center gap-1.5">
<TagItem <TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice( v-for="gameVersionGroup in getDisplayGameVersions(version).slice(
0, 0,
MAX_GAME_VERSION_TAGS, MAX_GAME_VERSION_TAGS,
)" )"
:key="`version-tag-${gameVersion}`" :key="`version-tag-${gameVersionGroup.label}`"
v-tooltip="getFilterTooltip(gameVersion)" v-tooltip="getFilterTooltip(gameVersionGroup.label)"
class="smart-clickable:allow-pointer-events" class="smart-clickable:allow-pointer-events"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)" :action="
() => versionFilters?.toggleFilters('gameVersion', gameVersionGroup.versions)
"
> >
{{ gameVersion }} {{ gameVersionGroup.label }}
</TagItem> </TagItem>
<Menu <Menu
v-if="getDisplayGameVersions(version).length > MAX_GAME_VERSION_TAGS" v-if="getDisplayGameVersions(version).length > MAX_GAME_VERSION_TAGS"
@@ -339,15 +348,16 @@
<template #popper> <template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1"> <div class="flex max-w-[20rem] flex-wrap gap-1">
<TagItem <TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice( v-for="gameVersionGroup in getDisplayGameVersions(version).slice(
MAX_GAME_VERSION_TAGS, MAX_GAME_VERSION_TAGS,
)" )"
:key="`overflow-version-tag-${gameVersion}`" :key="`overflow-version-tag-${gameVersionGroup.label}`"
:action=" :action="
() => versionFilters?.toggleFilters('gameVersion', version.game_versions) () =>
versionFilters?.toggleFilters('gameVersion', gameVersionGroup.versions)
" "
> >
{{ gameVersion }} {{ gameVersionGroup.label }}
</TagItem> </TagItem>
</div> </div>
</template> </template>
@@ -461,7 +471,12 @@ import {
VersionChannelIndicator, VersionChannelIndicator,
VersionFilterControl, VersionFilterControl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { formatVersionsForDisplay, type GameVersionTag, type Version } from '@modrinth/utils' import {
type GameVersionTag,
getVersionGroupsForDisplay,
type Version,
type VersionDisplayGroup,
} from '@modrinth/utils'
import { Menu } from 'floating-vue' import { Menu } from 'floating-vue'
import { computed, type Ref, ref } from 'vue' import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -622,8 +637,8 @@ function hasNoModLoader(loaders: string[]): boolean {
) )
} }
function getDisplayGameVersions(version: DisplayVersion): string[] { function getDisplayGameVersions(version: DisplayVersion): VersionDisplayGroup[] {
return formatVersionsForDisplay(version.game_versions, props.gameVersions) return getVersionGroupsForDisplay(version.game_versions, props.gameVersions)
} }
function getFilterTooltip(filter: string): string { function getFilterTooltip(filter: string): string {
@@ -243,31 +243,35 @@ if (selectedGameVersions.value.some((version) => !isReleaseGameVersion(version))
showSnapshots.value = true showSnapshots.value = true
} }
async function toggleFilters(type: FilterType, filters: Filter[]) { function selectedFiltersOfType(type: FilterType) {
for (const filter of filters) { if (type === 'channel') {
await toggleFilter(type, filter, true) return selectedChannels
} else if (type === 'gameVersion') {
return selectedGameVersions
} else {
return selectedPlatforms
} }
}
function toggleFilters(type: FilterType, filters: Filter[]) {
const selected = selectedFiltersOfType(type)
const allSelected = filters.every((filter) => selected.value.includes(filter))
selected.value = allSelected
? selected.value.filter((x) => !filters.includes(x))
: [...selected.value, ...filters.filter((filter) => !selected.value.includes(filter))]
updateFilters() updateFilters()
} }
async function toggleFilter(type: FilterType, filter: Filter, bulk = false) { function toggleFilter(type: FilterType, filter: Filter) {
if (type === 'channel') { const selected = selectedFiltersOfType(type)
selectedChannels.value = selectedChannels.value.includes(filter)
? selectedChannels.value.filter((x) => x !== filter) selected.value = selected.value.includes(filter)
: [...selectedChannels.value, filter] ? selected.value.filter((x) => x !== filter)
} else if (type === 'gameVersion') { : [...selected.value, filter]
selectedGameVersions.value = selectedGameVersions.value.includes(filter)
? selectedGameVersions.value.filter((x) => x !== filter) updateFilters()
: [...selectedGameVersions.value, filter]
} else if (type === 'platform') {
selectedPlatforms.value = selectedPlatforms.value.includes(filter)
? selectedPlatforms.value.filter((x) => x !== filter)
: [...selectedPlatforms.value, filter]
}
if (!bulk) {
updateFilters()
}
} }
function updateSelectedGameVersions(versions: string[]) { function updateSelectedGameVersions(versions: string[]) {
@@ -302,7 +306,7 @@ function updateShowSnapshots(value: boolean, _event?: MouseEvent) {
} }
} }
async function clearFilters() { function clearFilters() {
selectedChannels.value = [] selectedChannels.value = []
selectedGameVersions.value = [] selectedGameVersions.value = []
selectedPlatforms.value = [] selectedPlatforms.value = []
+49 -25
View File
@@ -66,6 +66,11 @@ export type PlatformTag = {
supported_project_types: DisplayProjectType[] supported_project_types: DisplayProjectType[]
} }
export type VersionDisplayGroup = {
label: string
versions: string[]
}
export function getVersionsToDisplay(project, allGameVersions: GameVersionTag[]) { export function getVersionsToDisplay(project, allGameVersions: GameVersionTag[]) {
return formatVersionsForDisplay(project.game_versions.slice(), allGameVersions) return formatVersionsForDisplay(project.game_versions.slice(), allGameVersions)
} }
@@ -74,6 +79,13 @@ export function formatVersionsForDisplay(
gameVersions: string[], gameVersions: string[],
allGameVersions: GameVersionTag[], allGameVersions: GameVersionTag[],
) { ) {
return getVersionGroupsForDisplay(gameVersions, allGameVersions).map((group) => group.label)
}
export function getVersionGroupsForDisplay(
gameVersions: string[],
allGameVersions: GameVersionTag[],
): VersionDisplayGroup[] {
const inputVersions = gameVersions.slice() const inputVersions = gameVersions.slice()
const allVersions = allGameVersions.slice() const allVersions = allGameVersions.slice()
@@ -112,26 +124,33 @@ export function formatVersionsForDisplay(
) )
const projectVersionsGrouped = groupVersions(releaseVersions, true) const projectVersionsGrouped = groupVersions(releaseVersions, true)
const releaseVersionsAsRanges = projectVersionsGrouped.map(({ major, minor }) => { const releaseVersionsAsRanges: VersionDisplayGroup[] = projectVersionsGrouped.map(
if (minor.length === 1) { ({ major, minor }) => {
return formatMinecraftMinorVersion(major, minor[0]) const versions = minor.map((minorVersion) => formatMinecraftMinorVersion(major, minorVersion))
}
const range = allReleasesGrouped.find((x) => x.major === major) if (minor.length === 1) {
return { label: versions[0], versions }
}
if (range?.minor.every((value, index) => value === minor[index])) { const range = allReleasesGrouped.find((x) => x.major === major)
return `${major}.x`
}
return `${formatMinecraftMinorVersion(major, minor[0])}${formatMinecraftMinorVersion(major, minor[minor.length - 1])}` if (range?.minor.every((value, index) => value === minor[index])) {
}) return { label: `${major}.x`, versions }
}
return {
label: `${formatMinecraftMinorVersion(major, minor[0])}${formatMinecraftMinorVersion(major, minor[minor.length - 1])}`,
versions,
}
},
)
const legacyVersionsAsRanges = groupConsecutiveIndices( const legacyVersionsAsRanges = groupConsecutiveIndices(
inputVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)), inputVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)),
allLegacy, allLegacy,
) )
let output = [...legacyVersionsAsRanges] let output: VersionDisplayGroup[] = [...legacyVersionsAsRanges]
// show all snapshots if there's no release versions // show all snapshots if there's no release versions
if (releaseVersionsAsRanges.length === 0) { if (releaseVersionsAsRanges.length === 0) {
@@ -141,14 +160,14 @@ export function formatVersionsForDisplay(
const snapshotVersionsAsRanges = const snapshotVersionsAsRanges =
snapshotVersions.length > 3 snapshotVersions.length > 3
? groupConsecutiveIndices(snapshotVersions, allSnapshots) ? groupConsecutiveIndices(snapshotVersions, allSnapshots)
: snapshotVersions : snapshotVersions.map((version) => ({ label: version, versions: [version] }))
output = [...snapshotVersionsAsRanges, ...output] output = [...snapshotVersionsAsRanges, ...output]
} else { } else {
output = [...releaseVersionsAsRanges, ...output] output = [...releaseVersionsAsRanges, ...output]
} }
if (releaseVersionsAsRanges.length > 0 && latestSnapshot) { if (releaseVersionsAsRanges.length > 0 && latestSnapshot) {
output = [latestSnapshot, ...output] output = [{ label: latestSnapshot, versions: [latestSnapshot] }, ...output]
} }
return output return output
} }
@@ -188,7 +207,10 @@ function groupVersions(versions: string[], consecutive = false) {
.reverse() .reverse()
} }
function groupConsecutiveIndices(versions: string[], referenceList: GameVersionTag[]) { function groupConsecutiveIndices(
versions: string[],
referenceList: GameVersionTag[],
): VersionDisplayGroup[] {
if (!versions || versions.length === 0) { if (!versions || versions.length === 0) {
return [] return []
} }
@@ -202,21 +224,23 @@ function groupConsecutiveIndices(versions: string[], referenceList: GameVersionT
.slice() .slice()
.sort((a, b) => referenceMap.get(a) - referenceMap.get(b)) .sort((a, b) => referenceMap.get(a) - referenceMap.get(b))
const ranges: string[] = [] const ranges: VersionDisplayGroup[] = []
let start = sortedList[0] let rangeStartIndex = 0
let previous = sortedList[0]
for (let i = 1; i < sortedList.length; i++) { for (let i = 1; i <= sortedList.length; i++) {
const current = sortedList[i] if (
if (referenceMap.get(current) !== referenceMap.get(previous) + 1) { i === sortedList.length ||
ranges.push(validateRange(`${previous}${start}`)) referenceMap.get(sortedList[i]) !== referenceMap.get(sortedList[i - 1]) + 1
start = current ) {
const members = sortedList.slice(rangeStartIndex, i)
ranges.push({
label: validateRange(`${members[members.length - 1]}${members[0]}`),
versions: members,
})
rangeStartIndex = i
} }
previous = current
} }
ranges.push(validateRange(`${previous}${start}`))
return ranges return ranges
} }