From 93eb59f5961bc73273c592ca9aabd48bdd708e48 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 26 Oct 2026 14:06:29 -0600 Subject: [PATCH 1/2] fix(stats): redirect comparison links on the server --- src/routes/stats/npm/$packages.tsx | 177 +++++++++----------------- tests/npm-comparison-redirect.test.ts | 46 +++++++ 2 files changed, 105 insertions(+), 118 deletions(-) create mode 100644 tests/npm-comparison-redirect.test.ts diff --git a/src/routes/stats/npm/$packages.tsx b/src/routes/stats/npm/$packages.tsx index 6f98b9f39..123b949d4 100644 --- a/src/routes/stats/npm/$packages.tsx +++ b/src/routes/stats/npm/$packages.tsx @@ -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, @@ -15,129 +18,67 @@ import { timeRangeSchema, viewModeSchema, } from './-utils' -import { Spinner } from '~/components/Spinner' import { chartHeightSchema, chartWidthSchema } 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 ( -
-
- -
- Loading {packages.join(' vs ')}... -
-
-
- ) +export function redirectNpmComparison({ + params, + search, +}: { + params: { packages: string } + search: v.InferOutput +}) { + const packages = parsePackagesFromUrl(params.packages) + if ( + !packages.length || + packages.length !== params.packages.split('-vs-').length + ) { + throw notFound() + } + throw redirect({ + to: '/stats/npm', + search: { + ...search, + packageGroups: packages.map((name) => ({ packages: [{ name }] })), + }, + replace: true, + }) } diff --git a/tests/npm-comparison-redirect.test.ts b/tests/npm-comparison-redirect.test.ts new file mode 100644 index 000000000..541cf8aa6 --- /dev/null +++ b/tests/npm-comparison-redirect.test.ts @@ -0,0 +1,46 @@ +/// +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']], + ['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', + Array(33).fill('react').join('-vs-'), + ]) { + await assert.rejects(runBeforeLoad(path), isNotFound) + } +}) From c59734d53d84928e4602dfdcab33224d99438e67 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 26 Oct 2026 14:25:31 -0600 Subject: [PATCH 2/2] fix(stats): reject overlong comparison package names --- src/routes/stats/npm/$packages.tsx | 9 +++++++-- tests/npm-comparison-redirect.test.ts | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/stats/npm/$packages.tsx b/src/routes/stats/npm/$packages.tsx index 123b949d4..e52db00b6 100644 --- a/src/routes/stats/npm/$packages.tsx +++ b/src/routes/stats/npm/$packages.tsx @@ -18,7 +18,11 @@ import { timeRangeSchema, viewModeSchema, } from './-utils' -import { chartHeightSchema, chartWidthSchema } from '~/utils/schemas' +import { + chartHeightSchema, + chartWidthSchema, + isNpmPackageName, +} from '~/utils/schemas' const timelineZoomTimeSchema = v.pipe(v.number(), v.integer(), v.minValue(0)) @@ -69,7 +73,8 @@ export function redirectNpmComparison({ const packages = parsePackagesFromUrl(params.packages) if ( !packages.length || - packages.length !== params.packages.split('-vs-').length + packages.length !== params.packages.split('-vs-').length || + !packages.every(isNpmPackageName) ) { throw notFound() } diff --git a/tests/npm-comparison-redirect.test.ts b/tests/npm-comparison-redirect.test.ts index 541cf8aa6..1635157dc 100644 --- a/tests/npm-comparison-redirect.test.ts +++ b/tests/npm-comparison-redirect.test.ts @@ -18,6 +18,8 @@ async function runBeforeLoad(packages: string) { 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']], @@ -39,6 +41,8 @@ test('invalid or oversized comparisons return not found', async () => { '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)