From f4c498af047eca527e2d4b5052e0f1ab3bbe988e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 24 Aug 2026 05:21:09 +0000 Subject: [PATCH 1/2] feat(ui): paginate the timeline in both directions around the selected version --- .../[[org]]/[packageName].vue | 191 +++++++++++------- i18n/locales/en.json | 1 + i18n/schema.json | 3 + nuxt.config.ts | 2 +- server/api/registry/timeline/[...pkg].get.ts | 26 ++- .../api/registry/timeline/pkg.get.spec.ts | 62 ++++++ 6 files changed, 210 insertions(+), 75 deletions(-) diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index 596e6370da..bf3433a339 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -75,35 +75,48 @@ const sort = usePermalink('sort', 'semver') // server-side so pagination totals and pages already exclude pre-releases. const stableOnly = useTimelineStableOnly() -// Paginated timeline data from server +// Paginated timeline data from server, anchored on the selected version: the +// initial page is the page-aligned slice containing it (`around=`), +// and more pages can be loaded in both directions. All follow-up requests use +// plain page-aligned offsets so every user shares the same page cache entries. const PAGE_SIZE = 25 const timelineEntries = ref([]) +// Offset of the first loaded entry in the full sorted list (page-aligned) +const timelineOffset = ref(0) const totalVersions = ref(0) -const loadingMore = ref(false) -const loadError = ref(false) +const loadingNewer = ref(false) +const loadingOlder = ref(false) +const loadError = ref(false) -const hasMore = computed(() => timelineEntries.value.length < totalVersions.value) +// Bumped whenever the list is replaced wholesale (package/version/sort/filter +// change) so in-flight load-more requests from the old window are discarded. +let listEpoch = 0 + +const hasNewer = computed(() => timelineOffset.value > 0) +const hasOlder = computed( + () => timelineOffset.value + timelineEntries.value.length < totalVersions.value, +) async function fetchTimeline( - offset: number, + page: { offset: number } | { around: string }, pkgName: string = packageName.value, sortOrder: TimelineSort = sort.value, stable: boolean = stableOnly.value, ): Promise { return $fetch(`/api/registry/timeline/${pkgName}`, { - query: { offset, 'limit': PAGE_SIZE, 'sort': sortOrder, 'stable-only': String(stable) }, + query: { ...page, 'limit': PAGE_SIZE, 'sort': sortOrder, 'stable-only': String(stable) }, }) } // Initial load - useAsyncData serializes the full response across SSR to client. -// The key is a stable string (evaluated once); subsequent package/sort/filter -// changes are handled by the reload watcher below so we control the page size. +// The key is a stable string (evaluated once); subsequent package/version/sort/ +// filter changes are handled by the re-anchor watcher below. const initialLoadError = ref(false) const { data: initialTimeline, status: initialStatus } = await useAsyncData( - `timeline:${packageName.value}:${sort.value}:${stableOnly.value}`, - () => fetchTimeline(0), + `timeline:${packageName.value}:${version.value}:${sort.value}:${stableOnly.value}`, + () => fetchTimeline({ around: version.value }), ) watch( @@ -111,7 +124,9 @@ watch( data => { initialLoadError.value = false if (data) { + listEpoch++ timelineEntries.value = data.versions + timelineOffset.value = data.offset totalVersions.value = data.total } else { initialLoadError.value = true @@ -120,28 +135,44 @@ watch( { immediate: true }, ) -async function loadMore() { - if (loadingMore.value) return - loadingMore.value = true - loadError.value = false - // Capture the request context; the package, sort or filter can change while - // the request is in flight, after which the reload watcher replaces the list. - const pkgName = packageName.value - const sortOrder = sort.value - const stable = stableOnly.value - const isStale = () => - pkgName !== packageName.value || sortOrder !== sort.value || stable !== stableOnly.value +async function loadMore(direction: 'newer' | 'older') { + const loading = direction === 'newer' ? loadingNewer : loadingOlder + if (loading.value) return + loading.value = true + if (loadError.value === direction) loadError.value = false + // Capture the window generation; a package, version, sort or filter change + // while the request is in flight replaces the window and bumps the epoch. + const epoch = listEpoch try { - const offset = timelineEntries.value.length - const data = await fetchTimeline(offset, pkgName, sortOrder, stable) - if (isStale()) return - timelineEntries.value = [...timelineEntries.value, ...data.versions] + const offset = + direction === 'newer' + ? Math.max(0, timelineOffset.value - PAGE_SIZE) + : timelineOffset.value + timelineEntries.value.length + const data = await fetchTimeline({ offset }) + if (epoch !== listEpoch) return + if (direction === 'newer') { + // Guard against overlap in case the boundary shifted (never in practice, + // both offsets are page-aligned) + const prepended = data.versions.slice(0, timelineOffset.value - offset) + // Prepending grows the list above the viewport; compensate the scroll so + // the content the user is looking at stays put (native scroll anchoring + // is disabled on the list, and not supported everywhere). + const doc = document.documentElement + const previousHeight = doc.scrollHeight + const previousScrollY = window.scrollY + timelineEntries.value = [...prepended, ...timelineEntries.value] + timelineOffset.value = offset + await nextTick() + window.scrollTo({ top: previousScrollY + (doc.scrollHeight - previousHeight) }) + } else { + timelineEntries.value = [...timelineEntries.value, ...data.versions] + } totalVersions.value = data.total - fetchSizes(offset, pkgName, sortOrder, stable) + fetchSizes(offset) } catch { - if (!isStale()) loadError.value = true + if (epoch === listEpoch) loadError.value = direction } finally { - loadingMore.value = false + loading.value = false } } @@ -187,52 +218,50 @@ async function fetchSizes( } } -// Fetch sizes for the first `pageCount` pages (one request per page). -function fetchSizesPages( - pageCount: number, - pkgName: string = packageName.value, - sortOrder: TimelineSort = sort.value, - stable: boolean = stableOnly.value, -) { - for (let page = 0; page < pageCount; page++) { - fetchSizes(page * PAGE_SIZE, pkgName, sortOrder, stable) - } -} - // Fetch sizes for the initial page if (import.meta.client) { watch( initialTimeline, - () => { - fetchSizes(0) + data => { + if (data) fetchSizes(data.offset) }, { immediate: true }, ) - // When the package, sort or stable-only filter changes, re-fetch as many - // PAGE_SIZE pages as the user had already paginated (a package change starts - // fresh from page one) so their position is preserved. Fetching per page reuses - // each page's cache instead of issuing one oversized request. - watch([packageName, sort, stableOnly], async ([pkgName, sortOrder, stable], [previousPkg]) => { - loadError.value = false - const pageCount = - pkgName === previousPkg ? Math.max(1, Math.ceil(timelineEntries.value.length / PAGE_SIZE)) : 1 - const isStale = () => - pkgName !== packageName.value || sortOrder !== sort.value || stable !== stableOnly.value - try { - const pages = await Promise.all( - Array.from({ length: pageCount }, (_, page) => - fetchTimeline(page * PAGE_SIZE, pkgName, sortOrder, stable), - ), - ) - if (isStale()) return - timelineEntries.value = pages.flatMap(page => page.versions) - totalVersions.value = pages[0]?.total ?? 0 - fetchSizesPages(pageCount, pkgName, sortOrder, stable) - } catch { - if (!isStale()) initialLoadError.value = true - } - }) + // When the package, selected version, sort or stable-only filter changes, + // re-anchor the timeline on the page containing the selected version. A + // version change alone keeps the current window when the version is already + // loaded (no refetch needed). + watch( + [packageName, version, sort, stableOnly], + async ([pkgName, ver, sortOrder, stable], [previousPkg, , previousSort, previousStable]) => { + if ( + pkgName === previousPkg && + sortOrder === previousSort && + stable === previousStable && + timelineEntries.value.some(entry => entry.version === ver) + ) { + return + } + loadError.value = false + const isStale = () => + pkgName !== packageName.value || + ver !== version.value || + sortOrder !== sort.value || + stable !== stableOnly.value + try { + const data = await fetchTimeline({ around: ver }, pkgName, sortOrder, stable) + if (isStale()) return + listEpoch++ + timelineEntries.value = data.versions + timelineOffset.value = data.offset + totalVersions.value = data.total + fetchSizes(data.offset, pkgName, sortOrder, stable) + } catch { + if (!isStale()) initialLoadError.value = true + } + }, + ) } const bytesFormatter = useBytesFormatter() @@ -439,8 +468,26 @@ useSeoMeta({
+ +
+ +

+ {{ $t('package.timeline.load_error') }} +

+
+ -
    +
    - -
    + +
    -

    +

    {{ $t('package.timeline.load_error') }}

    diff --git a/i18n/locales/en.json b/i18n/locales/en.json index b0e28f2c8a..e9f20fc575 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -641,6 +641,7 @@ "no_match_filter": "No versions match {filter}" }, "timeline": { + "load_newer": "Load newer versions", "load_more": "Load more", "load_error": "Failed to load timeline. Please try again later.", "no_stable_versions": "No stable versions to show. Turn off \u201cstable only\u201d to include pre-releases.", diff --git a/i18n/schema.json b/i18n/schema.json index 254dda9aed..b3dc4627d4 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1927,6 +1927,9 @@ "timeline": { "type": "object", "properties": { + "load_newer": { + "type": "string" + }, "load_more": { "type": "string" }, diff --git a/nuxt.config.ts b/nuxt.config.ts index 95e3632278..4d1d1ac954 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -126,7 +126,7 @@ export default defineNuxtConfig({ isr: { expiration: 300, passQuery: true, - allowQuery: ['offset', 'limit', 'sort', 'stable-only'], + allowQuery: ['offset', 'limit', 'sort', 'stable-only', 'around'], }, }, '/api/changelog/md/**': { diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 52e85dd7b8..eda43bef87 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -34,6 +34,8 @@ export interface TimelineVersion { export interface TimelineResponse { versions: TimelineVersion[] total: number + /** Effective offset of the returned page (page-aligned when `around` is used) */ + offset: number } export interface SubEvent { @@ -51,8 +53,15 @@ export interface SubEvent { * sorts (descending) by publish time (default) or by semver (`sort=semver`), * then paginates. * + * Instead of an explicit `offset`, `around=` returns the page-aligned + * slice containing that version (`offset = floor(index / limit) * limit`), so + * anchored requests still map onto the same shared page boundaries. The + * response reports the effective `offset` so clients can paginate from there + * in both directions. + * * Examples: * - /api/registry/timeline/packageName?offset=0&limit=25 + * - /api/registry/timeline/packageName?around=3.0.0&limit=25 * - /api/registry/timeline/@scope/packageName?offset=0&limit=25&sort=semver&stable-only=true */ export default defineCachedEventHandler( @@ -70,10 +79,11 @@ export default defineCachedEventHandler( } const query = getQuery(event) - const offset = Math.max(0, Number(query.offset) || 0) + let offset = Math.max(0, Number(query.offset) || 0) const limit = Math.max(1, Math.min(100, Number(query.limit) || DEFAULT_LIMIT)) const sort = parseTimelineSort(query.sort) const stableOnly = parseStableOnly(query['stable-only']) + const around = typeof query.around === 'string' ? query.around : undefined try { const packument = await fetchNpmPackage(packageName) @@ -96,6 +106,13 @@ export default defineCachedEventHandler( allVersions.sort((a, b) => Date.parse(packument.time[b]!) - Date.parse(packument.time[a]!)) } + if (around) { + // Snap to the page boundary containing the anchor version so anchored + // requests reuse the same page slices as plain offset pagination. + const index = allVersions.indexOf(around) + offset = index === -1 ? 0 : Math.floor(index / limit) * limit + } + const versions = allVersions.slice(offset, offset + limit) const versionsData = versions @@ -126,6 +143,7 @@ export default defineCachedEventHandler( return { versions: versionsData, total: allVersions.length, + offset, } satisfies TimelineResponse } catch (error: unknown) { handleApiError(error, { @@ -143,7 +161,11 @@ export default defineCachedEventHandler( const limit = Math.max(1, Math.min(100, Number(query.limit) || DEFAULT_LIMIT)) const sort = parseTimelineSort(query.sort) const stableOnly = parseStableOnly(query['stable-only']) - return `timeline:v1:${getRouterParam(event, 'pkg')}:${sort}:${stableOnly}:${offset}:${limit}` + const around = typeof query.around === 'string' ? query.around : undefined + // `around` supersedes `offset`, so anchored requests get their own key + // (one per anchor version - same cardinality as the per-version pages). + const page = around ? `around=${around}` : offset + return `timeline:v2:${getRouterParam(event, 'pkg')}:${sort}:${stableOnly}:${page}:${limit}` }, }, ) diff --git a/test/unit/server/api/registry/timeline/pkg.get.spec.ts b/test/unit/server/api/registry/timeline/pkg.get.spec.ts index a2dfd3fee2..24ece3429f 100644 --- a/test/unit/server/api/registry/timeline/pkg.get.spec.ts +++ b/test/unit/server/api/registry/timeline/pkg.get.spec.ts @@ -143,6 +143,68 @@ describe('timeline API', () => { expect(result.versions).toHaveLength(1) // sorted newest first: 3.0.0, 2.0.0, 1.0.0 → offset 1 = 2.0.0 expect(result.versions[0]!.version).toBe('2.0.0') + expect(result.offset).toBe(1) + }) + + it('returns the page-aligned slice containing the anchor version for around=', async () => { + routerParam = 'my-pkg' + queryParams = { around: '1.0.3', limit: 10, sort: 'semver' } + + const versions: Record = {} + const time: Record = {} + for (let i = 1; i <= 30; i++) { + const v = `1.0.${i}` + versions[v] = {} + time[v] = new Date(2024, 0, i).toISOString() + } + + fetchNpmPackageMock.mockResolvedValue(makePackument({ versions, time })) + + const result = await handler(fakeEvent) + // semver descending: 1.0.30 … 1.0.1 → 1.0.3 is at index 27 → page 2 (offset 20) + expect(result.offset).toBe(20) + expect(result.versions.map(v => v.version)).toContain('1.0.3') + expect(result.versions).toHaveLength(10) + expect(result.versions[0]!.version).toBe('1.0.10') + expect(result.total).toBe(30) + }) + + it('around= takes precedence over offset', async () => { + routerParam = 'my-pkg' + queryParams = { around: '1.0.0', offset: 2, limit: 1 } + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {}, '2.0.0': {} }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.offset).toBe(1) + expect(result.versions.map(v => v.version)).toEqual(['1.0.0']) + }) + + it('falls back to the first page when the around version is unknown', async () => { + routerParam = 'my-pkg' + queryParams = { around: '9.9.9', limit: 1 } + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {}, '2.0.0': {} }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.offset).toBe(0) + expect(result.versions.map(v => v.version)).toEqual(['2.0.0']) }) it('defaults offset to 0 and limit to 25', async () => { From 2663908a832cf7e587e46d06652ff7cdd7002f1d Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 24 Aug 2026 06:54:23 +0000 Subject: [PATCH 2/2] fix(ui): invalidate in-flight timeline load-more before re-anchoring --- app/pages/package-timeline/[[org]]/[packageName].vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index bf3433a339..2018292edc 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -243,6 +243,9 @@ if (import.meta.client) { ) { return } + // Invalidate any in-flight load-more from the previous window before the + // request goes out, so a stale response can't commit while we re-anchor. + listEpoch++ loadError.value = false const isStale = () => pkgName !== packageName.value || @@ -252,7 +255,6 @@ if (import.meta.client) { try { const data = await fetchTimeline({ around: ver }, pkgName, sortOrder, stable) if (isStale()) return - listEpoch++ timelineEntries.value = data.versions timelineOffset.value = data.offset totalVersions.value = data.total