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
1 change: 1 addition & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ jest.mock("react", () => {
// Override untable_cache method to avoid caching in tests
jest.mock("next/cache", () => ({
unstable_cache: (fn) => fn,
cacheLife: jest.fn(),
}));
1 change: 1 addition & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
reactStrictMode: true,
reactCompiler: true,
serverExternalPackages: ["libnpmdiff", "npm-package-arg", "pacote"],
cacheComponents: true,
};

export default nextConfig;
6 changes: 6 additions & 0 deletions src/app/[...parts]/_page/BundlephobiaDiff.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cacheLife } from "next/cache";
import bundlephobia from "^/lib/api/bundlephobia";
import { Bundlephobia } from "^/lib/Services";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
Expand All @@ -21,6 +22,11 @@ const BundlephobiaDiffInner = async ({
a,
b,
}: BundlephobiaDiffProps) => {
"use cache";

// The shortest cacheLife that `bundlephobia` uses is hours, so we can use that here too.
cacheLife("hours");

const { result, time } = await measuredPromise(bundlephobia(specs));

if (result == null) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/[...parts]/_page/DiffIntro/SpecBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const SpecBox = forwardRef<HTMLElement, SpecBoxProps>(
<section {...props} ref={ref}>
<Pkg pkg={pkg} className={cx("px-1", pkgClassName)} />
<PublishDate
key={"publishdate-" + simplePackageSpecToString(pkg)}
suspenseKey={"publishdate-" + simplePackageSpecToString(pkg)}
pkg={pkg}
className="font-normal"
/>
Expand Down
5 changes: 5 additions & 0 deletions src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Options } from "libnpmdiff";
import { cacheLife } from "next/cache";
import { Suspense } from "react";
import type { FileData } from "react-diff-view";
import Stack from "^/components/ui/Stack";
Expand All @@ -18,6 +19,10 @@ export interface NpmDiffProps {
}

const NpmDiff = async ({ a, b, specs, options }: NpmDiffProps) => {
"use cache";

cacheLife("max");

const diff = await npmDiff(specs, options);

const files: FileData[] = gitDiffParse(diff);
Expand Down
6 changes: 6 additions & 0 deletions src/app/[...parts]/_page/PackagephobiaDiff.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cacheLife } from "next/cache";
import packagephobia from "^/lib/api/packagephobia";
import { Packagephobia } from "^/lib/Services";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
Expand All @@ -18,6 +19,11 @@ const PackagephobiaDiffInner = async ({
a,
b,
}: PackagephobiaDiffProps) => {
"use cache";

// Cache for the shortest window that packagephobia is cached
cacheLife("hours");

const { result, time } = await measuredPromise(packagephobia(specs));

if (result == null) {
Expand Down
18 changes: 13 additions & 5 deletions src/app/[...parts]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Metadata } from "next";
import { redirect } from "next/navigation";
import { type JSX } from "react";
import { type JSX, Suspense } from "react";
import { type ViewType } from "react-diff-view";
import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec";
import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files";
Expand Down Expand Up @@ -35,7 +35,7 @@ export async function generateMetadata({
};
}

const DiffPage = async ({
const DiffPageInner = async ({
params,
searchParams,
}: DiffPageProps): Promise<JSX.Element> => {
Expand Down Expand Up @@ -79,15 +79,15 @@ const DiffPage = async ({
a={a}
b={b}
specs={canonicalSpecs}
key={
suspenseKey={
"bundlephobia-" + canonicalSpecs.join("...")
}
/>
<PackagephobiaDiff
a={a}
b={b}
specs={canonicalSpecs}
key={
suspenseKey={
"packagephobia-" +
canonicalSpecs.join("...")
}
Expand All @@ -101,11 +101,19 @@ const DiffPage = async ({
b={b}
specs={canonicalSpecs}
options={options}
key={JSON.stringify([canonicalSpecs, options])}
suspenseKey={JSON.stringify([canonicalSpecs, options])}
/>
</>
);
}
};

const DiffPage = (props: DiffPageProps) => {
return (
<Suspense>
<DiffPageInner {...props} />
</Suspense>
);
};

export default DiffPage;
24 changes: 19 additions & 5 deletions src/app/_layout/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Link from "next/link";
import { forwardRef, type HTMLAttributes } from "react";
import { forwardRef, type HTMLAttributes, Suspense } from "react";
import Heading from "^/components/ui/Heading";
import { cx } from "^/lib/cva";
import ColorModeToggle from "./ColorModeToggle";
import GithubLink from "./GithubLink";
import NavLink from "./NavLink";
import NavLink, { NavLinkFallback } from "./NavLink";

export interface HeaderProps extends HTMLAttributes<HTMLElement> {}

Expand Down Expand Up @@ -34,9 +34,23 @@ const Header = forwardRef<HTMLElement, HeaderProps>(
</Heading>
</Link>
<div className="flex items-center justify-end">
<NavLink href="/about">about</NavLink>
<span>/</span>
<NavLink href="/about/api">api</NavLink>
<Suspense
fallback={
<>
<NavLinkFallback href="/about">
about
</NavLinkFallback>
<span>/</span>
<NavLinkFallback href="/about/api">
api
</NavLinkFallback>
</>
}
>
<NavLink href="/about">about</NavLink>
<span>/</span>
<NavLink href="/about/api">api</NavLink>
</Suspense>
</div>
</nav>
),
Expand Down
13 changes: 13 additions & 0 deletions src/app/_layout/Header/NavLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,17 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(function NavLink(
);
});

Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

Add a JSDoc comment explaining that this component is used as a Suspense fallback for NavLink, which reads from client state (usePathname) that may not be available during SSR.

Suggested change
/**
* Lightweight fallback version of {@link NavLink} used as a Suspense fallback.
*
* Unlike {@link NavLink}, this component does not read client navigation state
* via `usePathname`, so it is safe to render during SSR when that client state
* may not yet be available. It preserves the same visual styling without
* applying active-link highlighting.
*/

Copilot uses AI. Check for mistakes.
export const NavLinkFallback = forwardRef<HTMLAnchorElement, NavLinkProps>(
function NavLinkFallback({ href = "", className, ...props }, ref) {
return (
<Link
href={href}
className={navLinkVariants({ className })}
{...props}
ref={ref}
/>
);
},
);

export default NavLink;
7 changes: 5 additions & 2 deletions src/app/about/api/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { cacheLife } from "next/cache";
import ExternalLink from "^/components/ExternalLink";
import Code from "^/components/ui/Code";
import Heading from "^/components/ui/Heading";
Expand All @@ -23,9 +24,11 @@ export const metadata: Metadata = {
description: "API documentation for npm-diff.app",
};

// We need nodejs since we use Npm libs https://beta.nextjs.org/docs/api-reference/segment-config#runtime
export const runtime = "nodejs";
const AboutApiPage = async () => {
"use cache";

cacheLife("max");

const specsOrVersions = splitParts(EXAMPLE_QUERY);
const { canonicalSpecs: specs } = await destination(specsOrVersions);

Expand Down
2 changes: 0 additions & 2 deletions src/app/api/-/versions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { NextResponse } from "next/server";
import getVersionData from "^/lib/api/npm/getVersionData";
import { type Version, VERSIONS_PARAMETER_PACKAGE } from "./types";

export const runtime = "edge";

export async function GET(request: Request) {
const start = Date.now();

Expand Down
5 changes: 5 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { cacheLife } from "next/cache";
import fallback from "^/lib/autocomplete/fallback";
import Intro from "./_page/Intro";
import IndexPageClient from "./page.client";

export interface IndexProps {}

const IndexPage = async ({}: IndexProps) => {
"use cache";

cacheLife("max");

const fallbackSuggestions = await fallback();

return (
Expand Down
73 changes: 67 additions & 6 deletions src/lib/api/bundlephobia/bundlephobia.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { cacheLife } from "next/cache";
import npa from "npm-package-arg";
import { USER_AGENT } from "../user-agent";
import type BundlephobiaResponse from "./BundlephobiaResponse";
import type BundlephobiaResults from "./BundlephobiaResults";

async function getPackage(spec: string): Promise<BundlephobiaResponse | null> {
"use cache";

const { scope } = npa(spec);

if (scope === "@types") {
cacheLife("max");
return null;
}

Expand All @@ -14,18 +19,74 @@ async function getPackage(spec: string): Promise<BundlephobiaResponse | null> {
`https://bundlephobia.com/api/size?package=${spec}`,
{
signal: AbortSignal.timeout(7_500),
headers: {
"User-Agent": USER_AGENT,
},
// Opt out of fetch-level caching, we have caching in function
cache: "no-store",
},
);

if (response.status !== 200) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 200) {
// If we succeed, cache as long as we're allowed
cacheLife("max");

const json: BundlephobiaResponse = await response.json();

return json;
} else if (response.status === 403) {
// Bundlephobia returns 403 forbidden for packages that are not supposed to be bundled.
// This is a stable, permanent behaviour, so we cache forever.
// For a list of packages; https://github.com/pastelsky/bundlephobia/blob/bundlephobia/server/config.js
cacheLife("max");

console.warn(`[${spec}] Bundlephobia returned 403 Forbidden`);

return null;
} else if (response.status === 404) {
// Package not found, cache for a while
cacheLife("hours");

console.warn(`[${spec}] Bundlephobia returned 404 Not Found`);

return null;
} else if (response.status === 500) {
// Server error, this is most likely because the package is too large or complex for Bundlephobia to handle.
// We don't want to retry too often, but we also don't want to cache forever in case the issue is resolved.
cacheLife("days");

const json: BundlephobiaResponse = await response.json();
console.error(
`[${spec}] Bundlephobia returned 500 Internal Server Error`,
);

return json;
return null;
} else {
// For other, unexpected statuses, we cache briefly and log the error.
cacheLife("hours");

console.error(
`[${spec}] Bundlephobia returned unexpected status: ${response.status} ${response.statusText}`,
);

return null;
}
} catch (e) {
console.error(`[${spec}] Bundlephobia error:`, e);
if (e instanceof Error && e.name === "TimeoutError") {
// Timing out is typical for large or complex packages.
// We don't want to retry too often, but we also don't want to cache forever in case the issue is resolved.
cacheLife("days");

console.error(`[${spec}] Bundlephobia request timed out`);

return null;
} else {
// For other, unexpected errors, we cache briefly and log the error.
cacheLife("hours");

console.error(`[${spec}] Bundlephobia error:`, e);

return null;
}
}

return null;
Expand Down
37 changes: 20 additions & 17 deletions src/lib/api/npm/getVersionData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { unstable_cache } from "next/cache";
import { cache } from "react";
import { cacheLife } from "next/cache";
import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
import { simplePackageSpecToString } from "^/lib/SimplePackageSpec";
import packument from "./packument";

// Packuments include a lot of data, often enough to make them too large for the cache.
Expand All @@ -17,17 +16,21 @@ export type VersionMap = {
[version: string]: VersionData;
};

async function getVersionDataInner(
spec: string | SimplePackageSpec,
): Promise<VersionMap> {
const specString =
typeof spec === "string" ? spec : simplePackageSpecToString(spec);
/**
* Separate function that takes only packagename for better caching.
*
* We want `[email protected]` and `[email protected]` to share the same cache entry for `a`.
*/
async function getVersionMap(packageName: string): Promise<VersionMap> {
"use cache";

cacheLife("hours");

const {
time,
"dist-tags": tags,
versions,
} = await packument(specString, {
} = await packument(packageName, {
// Response is too large to cache in Next's Data Cache; always fetch
cache: "no-store",
});
Expand All @@ -52,13 +55,13 @@ async function getVersionDataInner(
return versionData;
}

const getVersionData =
// Cache for request de-dupe
cache(
// unstable cache to cache between requests (5 minute TTL)
unstable_cache(getVersionDataInner, ["versionData"], {
revalidate: 300,
}),
);
async function getVersionData(
spec: string | SimplePackageSpec,
): Promise<VersionMap> {
const { name } =
typeof spec === "string" ? createSimplePackageSpec(spec) : spec;

return getVersionMap(name);
}

export default getVersionData;
Loading