Skip to content
Closed
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
31 changes: 22 additions & 9 deletions app/pages/~[username]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ import { debounce } from 'perfect-debounce'
import { normalizeSearchParam } from '#shared/utils/url'

const route = useRoute('~username')
const router = useRouter()

const username = computed(() => route.params.username.toLowerCase())

// Debounced URL update for page and filter/sort
const updateUrl = debounce((updates: { page?: number; filter?: string; sort?: string }) => {
router.replace({
query: {
...route.query,
page: updates.page && updates.page > 1 ? updates.page : undefined,
q: updates.filter || undefined,
sort: updates.sort && updates.sort !== 'downloads' ? updates.sort : undefined,
},
})
const url = new URL(window.location.href)
if (updates.page && updates.page > 1) {
url.searchParams.set('page', updates.page.toString())
} else {
url.searchParams.delete('page')
}
if (updates.filter) {
url.searchParams.set('q', updates.filter)
} else {
url.searchParams.delete('q')
}
if (updates.sort && updates.sort !== 'downloads') {
url.searchParams.set('sort', updates.sort)
} else {
url.searchParams.delete('sort')
}
window.history.replaceState(window.history.state, '', url)
}, 300)

type SortOption = 'downloads' | 'updated' | 'name-asc' | 'name-desc'
Expand All @@ -32,6 +40,11 @@ const debouncedUpdateUrl = debounce((filter: string, sort: string) => {
updateUrl({ filter, sort })
}, 300)

onBeforeUnmount(() => {
updateUrl.cancel()
debouncedUpdateUrl.cancel()
})

// Load all results when user starts filtering/sorting (so client-side filter works on full set)
watch([filterText, sortOption], ([filter, sort]) => {
if (filter !== '' || sort !== 'downloads') {
Expand Down
8 changes: 8 additions & 0 deletions modules/runtime/server/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,14 @@ async function fetchFromFixtures<T>(

// Handle version-specific requests for packuments (e.g., /create-vite/latest)
let data: T = rawData
if (match.type === 'user' && Array.isArray(rawData.objects)) {
const searchParams = new URL(url).searchParams
const from = Number.parseInt(searchParams.get('from') || '0', 10) || 0
const size = Number.parseInt(searchParams.get('size') || '', 10)
if (size > 0) {
data = { ...rawData, objects: rawData.objects.slice(from, from + size) } as T
}
}
if (match.type === 'packument' && match.version) {
const packument = rawData as any
let resolvedVersion = match.version
Expand Down
54 changes: 54 additions & 0 deletions test/e2e/profile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from './test-utils'

test.describe('User profile pagination', () => {
test('keeps the scroll position when loading the next page', async ({ page, goto }) => {
await goto('/~scroll-test?p=npm', { waitUntil: 'hydration' })
await expect(page.locator('[data-result-index="0"]')).toBeVisible()

const initialState = await page.evaluate(() => ({
scrollHeight: document.documentElement.scrollHeight,
innerHeight: window.innerHeight,
cards: document.querySelectorAll('[data-result-index]').length,
}))
expect(initialState.scrollHeight).toBeGreaterThan(initialState.innerHeight)
expect(initialState.cards).toBeGreaterThan(1)

const nextPageResponse = page.waitForResponse(
response => {
const url = new URL(response.url())
return (
response.status() === 200 &&
url.pathname.endsWith('/-/v1/search') &&
url.searchParams.get('from') === '50'
)
},
{ timeout: 15000 },
)

for (let attempt = 0; attempt < 5; attempt++) {
await page.evaluate(() => {
const scrollingElement = document.scrollingElement ?? document.documentElement
scrollingElement.scrollTop = scrollingElement.scrollHeight
window.dispatchEvent(new Event('scroll'))
})
await page.waitForTimeout(250)
if (new URL(page.url()).searchParams.get('page') === '2') break
}
await nextPageResponse
for (let attempt = 0; attempt < 2; attempt++) {
await page.evaluate(() => {
const scrollingElement = document.scrollingElement ?? document.documentElement
scrollingElement.scrollTop = scrollingElement.scrollHeight
window.dispatchEvent(new Event('scroll'))
})
await page.waitForTimeout(250)
if (new URL(page.url()).searchParams.get('page') === '2') break
}
await expect(page).toHaveURL(/[?&]page=2(?:&|$)/)

const scrollTop = await page.evaluate(() => window.scrollY)
expect(scrollTop).toBeGreaterThan(0)
await page.waitForTimeout(500)
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
})
})
8 changes: 7 additions & 1 deletion test/fixtures/mock-routes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ function matchNpmRegistry(urlString) {
const maintainerMatch = query.match(/^maintainer:(.+)$/)
if (maintainerMatch && maintainerMatch[1]) {
const fixture = readFixture(`users/${maintainerMatch[1]}.json`)
return json(fixture || { objects: [], total: 0, time: new Date().toISOString() })
if (!fixture) {
return json({ objects: [], total: 0, time: new Date().toISOString() })
}
const from = Number.parseInt(url.searchParams.get('from') || '0', 10) || 0
const size =
Number.parseInt(url.searchParams.get('size') || '', 10) || fixture.objects.length
return json({ ...fixture, objects: fixture.objects.slice(from, from + size) })
}

const searchName = query.replace(/:/g, '-')
Expand Down
Loading
Loading