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
19 changes: 19 additions & 0 deletions e2e/feed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ async function mockYoutubeThumbnails(page: Page) {
);
}

async function mockYoutubeEmbed(page: Page) {
await page.route("https://www.youtube-nocookie.com/embed/**", (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><title>Mock YouTube embed</title>",
}),
);
}

async function expectSafeExternalLink(link: Locator) {
await expect(link).toBeVisible();
const href = await link.getAttribute("href");
Expand All @@ -27,6 +37,14 @@ async function expectSafeExternalLink(link: Locator) {
test("verified feed lists vetted cards and opens the detail embed", async ({
page,
}) => {
await mockYoutubeThumbnails(page);
await mockYoutubeEmbed(page);
const runtimeErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
page.on("pageerror", (error) => runtimeErrors.push(error.message));

await page.goto("/");

// Shared header exposes the active Feed route.
Expand Down Expand Up @@ -120,6 +138,7 @@ test("verified feed lists vetted cards and opens the detail embed", async ({
await backLink.click();
await expect(page).toHaveURL(/\/$/);
await expect(page.locator(".feed-card").first()).toBeVisible();
expect(runtimeErrors).toEqual([]);
});

test("searches, filters, resets, and recovers across every feed state", async ({
Expand Down
2 changes: 1 addition & 1 deletion src/app/feed/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export default async function FeedDetail({
className={`score-num ${tone}`}
aria-label={`Cap Score ${item.capScore} out of 100`}
>
<CountUp to={item.capScore} />
<CountUp to={item.capScore} animate={false} />
</div>
<h2 id="feed-score-title" className={tone}>
{CAP_LABELS[item.capLabel]}
Expand Down
22 changes: 22 additions & 0 deletions src/components/react-bits/count-up.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { CountUp } from "./count-up";

describe("CountUp", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("renders a stable server-compatible value when animation is disabled", () => {
const requestFrame = vi.fn().mockReturnValue(1);
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
vi.stubGlobal("requestAnimationFrame", requestFrame);
vi.stubGlobal("cancelAnimationFrame", vi.fn());

render(<CountUp to={28} animate={false} />);

expect(screen.getByText("28")).toBeVisible();
expect(requestFrame).not.toHaveBeenCalled();
});
});
10 changes: 7 additions & 3 deletions src/components/react-bits/count-up.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type CountUpProps = {
from?: number;
durationMs?: number;
className?: string;
animate?: boolean;
};

// Animate only when we can both detect motion preference and drive frames.
Expand All @@ -33,14 +34,17 @@ export function CountUp({
from = 0,
durationMs = 200,
className,
animate = true,
}: CountUpProps) {
const [value, setValue] = useState(() => (canAnimate() ? from : to));
const [value, setValue] = useState(() =>
animate && canAnimate() ? from : to,
);
const frameRef = useRef<number | null>(null);

useEffect(() => {
// The initializer already resolves the resting value; only animate when we
// can drive frames, updating state asynchronously inside the rAF callback.
if (!canAnimate()) return;
if (!animate || !canAnimate()) return;

const start = performance.now();
const tick = (now: number) => {
Expand All @@ -57,7 +61,7 @@ export function CountUp({
return () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
};
}, [to, from, durationMs]);
}, [to, from, durationMs, animate]);

return <span className={className}>{value}</span>;
}
Loading