Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 111 additions & 62 deletions app/pages/package/[[org]]/[name]/versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const distTags = computed(() => versionSummary.value?.distTags ?? {})
const versionStrings = computed(() => versionSummary.value?.versions ?? [])
const versionTimes = computed(() => versionSummary.value?.time ?? {})

// ─── Phase 2: full metadata (loaded on first group expand) ────────────────────
// ─── Phase 2: full metadata (fired automatically after phase 1 completes) ────
// Fetches deprecated status, provenance, and exact times needed for version rows.

const fullVersionMap = shallowRef<Map<
Expand Down Expand Up @@ -82,7 +82,6 @@ function getVersionTime(version: string): string | undefined {
// ─── Version groups ───────────────────────────────────────────────────────────

const expandedGroups = ref(new Set<string>())
const loadingGroup = ref<string | null>(null)

const versionGroups = computed(() => {
const byKey = new Map<string, string[]>()
Expand All @@ -101,27 +100,42 @@ const versionGroups = computed(() => {
}))
})

async function toggleGroup(groupKey: string) {
const deprecatedGroupKeys = computed(() => {
if (!fullVersionMap.value) return new Set<string>()
const result = new Set<string>()
for (const group of versionGroups.value) {
if (group.versions.every(v => !!fullVersionMap.value!.get(v)?.deprecated))
result.add(group.groupKey)
}
return result
})

function toggleGroup(groupKey: string) {
if (expandedGroups.value.has(groupKey)) {
expandedGroups.value.delete(groupKey)
return
} else {
expandedGroups.value.add(groupKey)
}
expandedGroups.value.add(groupKey)
if (!fullVersionMap.value) {
loadingGroup.value = groupKey
try {
}

watch(
versionSummary,
async summary => {
if (summary) {
await ensureFullDataLoaded()
} finally {
loadingGroup.value = null
}
}
}
},
{ immediate: true },
)
Comment on lines +121 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unhandled promise rejection in async watch callback.

If fetchAllPackageVersions throws (e.g. network error), the error will be silently swallowed or cause an unhandled rejection. Consider adding error handling to improve resilience.

🛡️ Proposed fix to handle errors gracefully
 watch(
   versionSummary,
   async summary => {
     if (summary) {
-      await ensureFullDataLoaded()
+      try {
+        await ensureFullDataLoaded()
+      } catch (err) {
+        console.error('[versions] Failed to load full metadata:', err)
+      }
     }
   },
   { immediate: true },
 )

As per coding guidelines: "Use error handling patterns consistently".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
watch(
versionSummary,
async summary => {
if (summary) {
await ensureFullDataLoaded()
} finally {
loadingGroup.value = null
}
}
}
},
{ immediate: true },
)
watch(
versionSummary,
async summary => {
if (summary) {
try {
await ensureFullDataLoaded()
} catch (err) {
console.error('[versions] Failed to load full metadata:', err)
}
}
},
{ immediate: true },
)


// ─── Version filter ───────────────────────────────────────────────────────────

const versionFilterInput = ref('')
const versionFilter = refDebounced(versionFilterInput, 100)
const isFilterActive = computed(() => versionFilter.value.trim() !== '')
const isInvalidRange = computed(
() => isFilterActive.value && validRange(versionFilter.value.trim()) === null,
)

const filteredVersionSet = computed(() => {
const trimmed = versionFilter.value.trim()
Expand Down Expand Up @@ -198,14 +212,40 @@ const flatItems = computed<FlatItem[]>(() => {
<span class="text-fg-subtle shrink-0">/</span>
<h1 class="text-sm text-fg-muted shrink-0">{{ $t('package.versions.page_title') }}</h1>
</div>
<InputBase
v-model="versionFilterInput"
type="text"
:placeholder="$t('package.versions.version_filter_placeholder')"
:aria-label="$t('package.versions.version_filter_label')"
size="sm"
class="w-36 sm:w-44"
/>
<div class="relative">
<InputBase
v-model="versionFilterInput"
type="text"
:placeholder="$t('package.versions.filter_placeholder')"
:aria-label="$t('package.versions.filter_placeholder')"
:aria-invalid="isInvalidRange ? 'true' : undefined"
:aria-describedby="isInvalidRange ? 'version-filter-error' : undefined"
autocomplete="off"
size="sm"
class="w-36 sm:w-64"
:class="isInvalidRange ? 'pe-7 !border-red-500' : ''"
/>
<Transition
enter-active-class="transition-all duration-150"
enter-from-class="opacity-0 scale-60"
leave-active-class="transition-all duration-150"
leave-to-class="opacity-0 scale-60"
>
<TooltipApp
v-if="isInvalidRange"
:text="$t('package.versions.filter_invalid')"
position="bottom"
class="absolute end-0 inset-y-0 flex items-center pe-2"
>
<span
id="version-filter-error"
class="i-lucide:circle-alert w-3.5 h-3.5 text-red-500 block"
role="img"
:aria-label="$t('package.versions.filter_invalid')"
/>
</TooltipApp>
</Transition>
</div>
</div>
</header>

Expand All @@ -230,18 +270,26 @@ const flatItems = computed<FlatItem[]>(() => {
v-for="tag in latestTagRow!.tags.filter(t => t !== 'latest')"
:key="tag"
class="text-3xs font-semibold uppercase tracking-wide text-fg-subtle"
:title="tag"
>{{ tag }}</span
>
</div>
<LinkBase
:to="packageRoute(packageName, latestTagRow!.version)"
class="text-2xl font-semibold tracking-tight after:absolute after:inset-0 after:content-['']"
:title="latestTagRow!.version"
dir="ltr"
>{{ latestTagRow!.version }}</LinkBase
>
</div>
<!-- Right: date + provenance -->
<!-- Right: deprecated + date + provenance -->
<div class="flex flex-col items-end gap-1.5 shrink-0 relative z-10">
<span
v-if="fullVersionMap?.get(latestTagRow!.version)?.deprecated"
class="text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded"
:title="fullVersionMap!.get(latestTagRow!.version)!.deprecated"
>deprecated</span
>
<ProvenanceBadge
v-if="fullVersionMap?.get(latestTagRow!.version)?.hasProvenance"
:package-name="packageName"
Expand Down Expand Up @@ -276,6 +324,7 @@ const flatItems = computed<FlatItem[]>(() => {
v-for="tag in row.tags"
:key="tag"
class="text-3xs font-semibold uppercase tracking-wide text-fg-subtle"
:title="tag"
>{{ tag }}</span
>
</div>
Expand All @@ -284,30 +333,36 @@ const flatItems = computed<FlatItem[]>(() => {
<LinkBase
:to="packageRoute(packageName, row.version)"
class="text-sm flex-1 min-w-0 after:absolute after:inset-0 after:content-['']"
:title="row.version"
dir="ltr"
>
{{ row.version }}
</LinkBase>

<!-- Date -->
<DateTime
v-if="getVersionTime(row.version)"
:datetime="getVersionTime(row.version)!"
class="text-xs text-fg-subtle shrink-0 hidden sm:block"
year="numeric"
month="short"
day="numeric"
/>

<!-- Provenance -->
<ProvenanceBadge
v-if="fullVersionMap?.get(row.version)?.hasProvenance"
:package-name="packageName"
:version="row.version"
compact
:linked="false"
class="relative z-10 shrink-0"
/>
<!-- Deprecated + Date + Provenance -->
<div class="flex items-center gap-2 shrink-0 relative z-10">
<span
v-if="fullVersionMap?.get(row.version)?.deprecated"
class="text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded"
:title="fullVersionMap!.get(row.version)!.deprecated"
>deprecated</span
>
<DateTime
v-if="getVersionTime(row.version)"
:datetime="getVersionTime(row.version)!"
class="text-xs text-fg-subtle hidden sm:block"
year="numeric"
month="short"
day="numeric"
/>
<ProvenanceBadge
v-if="fullVersionMap?.get(row.version)?.hasProvenance"
:package-name="packageName"
:version="row.version"
compact
:linked="false"
/>
</div>
</div>
</div>
</section>
Expand Down Expand Up @@ -351,15 +406,9 @@ const flatItems = computed<FlatItem[]>(() => {
<span class="w-4 h-4 flex items-center justify-center text-fg-subtle shrink-0">
<Transition name="icon-swap" mode="out-in">
<span
v-if="loadingGroup === item.groupKey"
key="loading"
class="i-svg-spinners:ring-resize w-3 h-3"
aria-hidden="true"
/>
<span
v-else-if="isFilterActive"
v-if="isFilterActive"
key="search"
class="i-lucide:search w-3 h-3 animate-searching"
class="i-lucide:funnel w-3 h-3"
aria-hidden="true"
/>
<span
Expand All @@ -372,9 +421,16 @@ const flatItems = computed<FlatItem[]>(() => {
</Transition>
</span>
<span class="text-sm font-medium">{{ item.label }}</span>
<span
v-if="deprecatedGroupKeys.has(item.groupKey)"
class="text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded"
>deprecated</span
>
<span class="text-xs text-fg-subtle">({{ item.versions.length }})</span>
<span class="ms-auto flex items-center gap-3 shrink-0">
<span class="text-xs text-fg-muted" dir="ltr">{{ item.versions[0] }}</span>
<span class="text-xs text-fg-muted" :title="item.versions[0]" dir="ltr">{{
item.versions[0]
}}</span>
<DateTime
v-if="getVersionTime(item.versions[0])"
:datetime="getVersionTime(item.versions[0])!"
Expand Down Expand Up @@ -411,6 +467,11 @@ const flatItems = computed<FlatItem[]>(() => {
? 'i-lucide:octagon-alert'
: undefined
"
:title="
fullVersionMap?.get(item.version)?.deprecated
? $t('package.versions.deprecated_title', { version: item.version })
: item.version
"
dir="ltr"
>
{{ item.version }}
Expand All @@ -424,6 +485,7 @@ const flatItems = computed<FlatItem[]>(() => {
:key="tag"
class="text-4xs font-semibold uppercase tracking-wide"
:class="tag === 'latest' ? 'text-accent' : 'text-fg-subtle'"
:title="tag"
>
{{ tag }}
</span>
Expand Down Expand Up @@ -521,17 +583,4 @@ const flatItems = computed<FlatItem[]>(() => {
opacity: 0;
transform: scale(0.5);
}

@keyframes searching {
from {
transform: rotate(0deg) translateY(-2px) rotate(0deg);
}
to {
transform: rotate(360deg) translateY(-2px) rotate(-360deg);
}
}

.animate-searching {
animation: searching 1.2s linear infinite;
}
</style>
2 changes: 0 additions & 2 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,6 @@
},
"page_title": "Version History",
"current_tags": "Current Tags",
"version_filter_placeholder": "Filter versions…",
"version_filter_label": "Filter versions",
"no_match_filter": "No versions match {filter}"
},
"dependencies": {
Expand Down
2 changes: 0 additions & 2 deletions i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,6 @@
},
"page_title": "Histórico de versões",
"current_tags": "Tags atuais",
"version_filter_placeholder": "Filtrar versões...",
"version_filter_label": "Filtrar versões",
"no_match_filter": "Nenhuma versão corresponde a {filter}"
},
"dependencies": {
Expand Down
2 changes: 0 additions & 2 deletions i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,6 @@
},
"page_title": "版本历史",
"current_tags": "当前标签",
"version_filter_placeholder": "筛选版本…",
"version_filter_label": "筛选版本",
"no_match_filter": "没有与“{filter}”匹配的版本"
},
"dependencies": {
Expand Down
2 changes: 0 additions & 2 deletions i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,6 @@
},
"page_title": "版本歷史記錄",
"current_tags": "當前標籤",
"version_filter_placeholder": "篩選版本…",
"version_filter_label": "篩選版本",
"no_match_filter": "沒有版本符合 {filter}"
},
"dependencies": {
Expand Down
6 changes: 0 additions & 6 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1324,12 +1324,6 @@
"current_tags": {
"type": "string"
},
"version_filter_placeholder": {
"type": "string"
},
"version_filter_label": {
"type": "string"
},
"no_match_filter": {
"type": "string"
}
Expand Down
18 changes: 6 additions & 12 deletions test/nuxt/pages/PackageVersionsPage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('fast-npm-meta', async importOriginal => {
}
})

// Phase 2: full metadata (loaded on first group expand)
// Phase 2: full metadata (fired automatically after phase 1 completes)
const mockFetchAllPackageVersions = vi.fn()
vi.mock('~/utils/npm/api', async importOriginal => {
const actual = await importOriginal<typeof NpmApi>()
Expand Down Expand Up @@ -52,6 +52,7 @@ describe('package versions page', () => {
beforeEach(() => {
mockGetVersions.mockReset()
mockFetchAllPackageVersions.mockReset()
mockFetchAllPackageVersions.mockResolvedValue([])
clearNuxtData()
})

Expand Down Expand Up @@ -140,23 +141,16 @@ describe('package versions page', () => {
})
})

it('only fetches full metadata once across multiple group expansions', async () => {
it('fetches full metadata automatically after phase 1 completes, exactly once', async () => {
mockGetVersions.mockResolvedValue(makeVersionData(['2.0.0', '1.0.0'], { latest: '2.0.0' }))
mockFetchAllPackageVersions.mockResolvedValue([
{ version: '2.0.0', time: '2024-01-15T00:00:00.000Z', hasProvenance: false },
{ version: '1.0.0', time: '2024-01-10T00:00:00.000Z', hasProvenance: false },
])
const component = await mountPage()
await vi.waitFor(() => {
expect(component.findAll('button[aria-expanded="false"]').length).toBeGreaterThanOrEqual(2)
})

const [first, second] = component.findAll('button[aria-expanded="false"]')
await first!.trigger('click')
await vi.waitFor(() => expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1))
await mountPage()

await second!.trigger('click')
expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1))
})
})

Expand All @@ -173,7 +167,7 @@ describe('package versions page', () => {
expect(component.text()).toContain('3.x')
})

const input = component.find('input[placeholder="Filter versions\u2026"]')
const input = component.find('input[autocomplete="off"]')
await input.setValue('1.0')

await vi.waitFor(() => {
Expand Down
Loading