Skip to content
Merged
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
184 changes: 65 additions & 119 deletions src/routes/stats/npm/$packages.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as React from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import {
createFileRoute,
notFound,
redirect,
stripSearchParams,
} from '@tanstack/react-router'
import * as v from 'valibot'
import { seo } from '~/utils/seo'
import {
parsePackagesFromUrl,
transformModeSchema,
Expand All @@ -15,129 +18,72 @@ import {
timeRangeSchema,
viewModeSchema,
} from './-utils'
import { Spinner } from '~/components/Spinner'
import { chartHeightSchema, chartWidthSchema } from '~/utils/schemas'
import {
chartHeightSchema,
chartWidthSchema,
isNpmPackageName,
} from '~/utils/schemas'

const timelineZoomTimeSchema = v.pipe(v.number(), v.integer(), v.minValue(0))

export const Route = createFileRoute('/stats/npm/$packages')({
validateSearch: v.object({
range: v.fallback(v.optional(timeRangeSchema, '365-days'), '365-days'),
transform: v.fallback(v.optional(transformModeSchema, 'none'), 'none'),
binType: v.fallback(v.optional(binTypeSchema, 'weekly'), 'weekly'),
viewMode: v.fallback(v.optional(viewModeSchema, 'history'), 'history'),
chartType: v.fallback(v.optional(chartTypeSchema, 'line'), 'line'),
barOrientation: v.fallback(v.optional(barOrientationSchema), undefined),
barSort: v.fallback(v.optional(latestBarSortSchema, 'value'), 'value'),
bucketOffset: v.fallback(
v.optional(
v.pipe(v.number(), v.integer(), v.minValue(-5000), v.maxValue(0)),
0,
),
export const comparisonSearchSchema = v.object({
range: v.fallback(v.optional(timeRangeSchema, '365-days'), '365-days'),
transform: v.fallback(v.optional(transformModeSchema, 'none'), 'none'),
binType: v.fallback(v.optional(binTypeSchema, 'weekly'), 'weekly'),
viewMode: v.fallback(v.optional(viewModeSchema, 'history'), 'history'),
chartType: v.fallback(v.optional(chartTypeSchema, 'line'), 'line'),
barOrientation: v.fallback(v.optional(barOrientationSchema), undefined),
barSort: v.fallback(v.optional(latestBarSortSchema, 'value'), 'value'),
bucketOffset: v.fallback(
v.optional(
v.pipe(v.number(), v.integer(), v.minValue(-5000), v.maxValue(0)),
0,
),
playbackIntervalMs: v.fallback(
v.optional(playbackIntervalMsSchema, defaultPlaybackIntervalMs),
defaultPlaybackIntervalMs,
),
playbackLoop: v.fallback(v.optional(v.boolean(), false), false),
playbackPlaying: v.fallback(v.optional(v.boolean(), false), false),
showDataMode: v.fallback(v.optional(showDataModeSchema, 'all'), 'all'),
showLegend: v.fallback(v.optional(v.boolean(), false), false),
height: v.fallback(v.optional(chartHeightSchema, 400), 400),
width: v.fallback(v.optional(chartWidthSchema), undefined),
timelineStart: v.fallback(v.optional(timelineZoomTimeSchema), undefined),
timelineEnd: v.fallback(v.optional(timelineZoomTimeSchema), undefined),
}),
loader: ({ params }) => {
const packages = parsePackagesFromUrl(params.packages)
return { packages }
},
head: ({ loaderData }) => {
const packages = loaderData?.packages || []
const packageList = packages.join(' vs ')

// SEO-optimized title and description
const title =
packages.length === 1
? `${packages[0]} - NPM Download Statistics & Trends`
: `${packageList} - NPM Download Comparison`

const description =
packages.length === 1
? `View npm download statistics and trends for ${packages[0]}. Track weekly downloads, historical data, and growth patterns.`
: `Compare npm downloads for ${packageList}. View download trends, weekly statistics, and historical data to choose the best package for your project.`

const keywords = `${packages.join(', ')}, npm downloads, npm stats, package comparison, npm trends, download statistics`

// JSON-LD structured data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description,
url: `https://tanstack.com/stats/npm/${packages.join('-vs-')}`,
isPartOf: {
'@type': 'WebApplication',
name: 'TanStack NPM Stats',
url: 'https://tanstack.com/stats/npm',
},
about: packages.map((pkg) => ({
'@type': 'SoftwareApplication',
name: pkg,
applicationCategory: 'DeveloperApplication',
})),
}
0,
),
playbackIntervalMs: v.fallback(
v.optional(playbackIntervalMsSchema, defaultPlaybackIntervalMs),
defaultPlaybackIntervalMs,
),
playbackLoop: v.fallback(v.optional(v.boolean(), false), false),
playbackPlaying: v.fallback(v.optional(v.boolean(), false), false),
showDataMode: v.fallback(v.optional(showDataModeSchema, 'all'), 'all'),
showLegend: v.fallback(v.optional(v.boolean(), false), false),
height: v.fallback(v.optional(chartHeightSchema, 400), 400),
width: v.fallback(v.optional(chartWidthSchema), undefined),
timelineStart: v.fallback(v.optional(timelineZoomTimeSchema), undefined),
timelineEnd: v.fallback(v.optional(timelineZoomTimeSchema), undefined),
})

return {
meta: seo({
title,
description,
keywords,
}),
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify(jsonLd),
},
],
}
},
component: RouteComponent,
staticData: {
includeSearchInCanonical: true,
export const Route = createFileRoute('/stats/npm/$packages')({
validateSearch: comparisonSearchSchema,
search: {
middlewares: [stripSearchParams(v.parse(comparisonSearchSchema, {}))],
},
beforeLoad: redirectNpmComparison,
})

function RouteComponent() {
const { packages } = Route.useLoaderData()
const search = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })

// Navigate to the main page with packages as search params
// This happens client-side after SSR renders the meta tags
React.useEffect(() => {
navigate({
to: '/stats/npm',
search: {
...search,
packageGroups: packages.map((name: string) => ({
packages: [{ name }],
})),
},
replace: true,
})
}, [navigate, packages, search])

// Show loading while navigating
return (
<div className="min-h-dvh flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Spinner />
<div className="text-sm text-gray-600 dark:text-gray-400">
Loading {packages.join(' vs ')}...
</div>
</div>
</div>
)
export function redirectNpmComparison({
params,
search,
}: {
params: { packages: string }
search: v.InferOutput<typeof comparisonSearchSchema>
}) {
const packages = parsePackagesFromUrl(params.packages)
if (
!packages.length ||
packages.length !== params.packages.split('-vs-').length ||
!packages.every(isNpmPackageName)
) {
throw notFound()
}
throw redirect({
to: '/stats/npm',
search: {
...search,
packageGroups: packages.map((name) => ({ packages: [{ name }] })),
},
replace: true,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
50 changes: 50 additions & 0 deletions tests/npm-comparison-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/// <reference types="node" />
import assert from 'node:assert/strict'
import test from 'node:test'
import { isNotFound, isRedirect } from '@tanstack/react-router'
import * as v from 'valibot'
import {
comparisonSearchSchema,
redirectNpmComparison,
} from '../src/routes/stats/npm/$packages'

async function runBeforeLoad(packages: string) {
return redirectNpmComparison({
params: { packages },
search: v.parse(comparisonSearchSchema, { height: 600 }),
})
}

test('comparison links redirect before rendering and preserve selected packages', async () => {
for (const [path, names] of [
['react', ['react']],
['a'.repeat(214), ['a'.repeat(214)]],
['@scope~1' + 'a'.repeat(207), ['@scope/' + 'a'.repeat(207)]],
['react-vs-vue', ['react', 'vue']],
['@tanstack~1react-query', ['@tanstack/react-query']],
['@tanstack__react-query', ['@tanstack/react-query']],
] satisfies [string, string[]][]) {
await assert.rejects(runBeforeLoad(path), (error: unknown) => {
assert.ok(isRedirect(error))
assert.equal(error.options.to, '/stats/npm')
assert.deepEqual(error.options.search, {
...v.parse(comparisonSearchSchema, { height: 600 }),
packageGroups: names.map((name) => ({ packages: [{ name }] })),
})
return true
})
}
})

test('invalid or oversized comparisons return not found', async () => {
for (const path of [
'react-vs-',
'-vs-',
'react-vs-INVALID',
'a'.repeat(215),
'@scope~1' + 'a'.repeat(208),
Array(33).fill('react').join('-vs-'),
]) {
await assert.rejects(runBeforeLoad(path), isNotFound)
}
})
Loading