feat: implement resume preview loading enhancements and add tests for preview components

This commit is contained in:
Amruth Pillai
2026-05-11 00:59:16 +02:00
parent 4ebe9e5a67
commit 92a0e3ddb8
6 changed files with 248 additions and 14 deletions
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumePreviewClient } from "./preview.browser";
const previewMock = vi.hoisted(() => ({
builderResumeData: undefined as ResumeData | undefined,
localizedResumeData: [] as Array<ResumeData | undefined>,
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
}));
const resumeDataWithPageCount = (pageCount: number): ResumeData => ({
...sampleResumeData,
metadata: {
...sampleResumeData.metadata,
layout: {
...sampleResumeData.metadata.layout,
pages: sampleResumeData.metadata.layout.pages.slice(0, pageCount),
},
},
});
vi.mock("@react-pdf/renderer", () => ({
pdf: () => ({ toBlob: previewMock.toBlob }),
}));
vi.mock("@/libs/resume/pdf-document", () => ({
useLocalizedResumeDocument: (data?: ResumeData) => {
previewMock.localizedResumeData.push(data);
return data ? { type: "resume-document", data } : null;
},
}));
vi.mock("./builder-resume-draft", () => ({
useResumeData: () => previewMock.builderResumeData,
}));
vi.mock("./pdf-canvas", async () => {
const React = await import("react");
const pdfDocument = { numPages: 1 };
return {
PdfCanvasDocument: ({
children,
onLoadSuccess,
}: {
children: (document: typeof pdfDocument) => React.ReactNode;
onLoadSuccess: (document: typeof pdfDocument) => void;
}) => {
React.useEffect(() => {
onLoadSuccess(pdfDocument);
}, [onLoadSuccess]);
return React.createElement(React.Fragment, null, children(pdfDocument));
},
PdfCanvasPage: ({
onLoadSuccess,
onRenderSuccess,
pageNumber,
totalPages,
}: {
onLoadSuccess: (pageNumber: number, pageSize: { height: number; width: number }) => void;
onRenderSuccess?: () => void;
pageNumber: number;
totalPages: number;
}) => {
React.useEffect(() => {
onLoadSuccess(pageNumber, { height: 200, width: 100 });
onRenderSuccess?.();
}, [onLoadSuccess, onRenderSuccess, pageNumber]);
return React.createElement(
"div",
{ role: "img", "aria-label": `Resume page ${pageNumber} of ${totalPages}` },
"Rendered page",
);
},
};
});
describe("ResumePreviewClient", () => {
beforeEach(() => {
previewMock.builderResumeData = undefined;
previewMock.localizedResumeData = [];
previewMock.toBlob.mockReset();
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
});
it("renders a loading placeholder for each builder layout page while the PDF is generated", () => {
previewMock.builderResumeData = resumeDataWithPageCount(3);
previewMock.toBlob.mockImplementation(() => new Promise<Blob>(() => {}));
render(<ResumePreviewClient pageGap="1rem" pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3);
});
it("renders from explicit resume data when no builder resume is active", async () => {
render(
<ResumePreviewClient
data={sampleResumeData}
pageGap="1rem"
pageLayout="vertical"
pageScale={1.25}
showPageNumbers={false}
/>,
);
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
await waitFor(() => {
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
expect(previewMock.localizedResumeData).toContain(sampleResumeData);
});
});
@@ -6,7 +6,7 @@ import { cn } from "@reactive-resume/utils/style";
import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document";
import { useResumeData } from "./builder-resume-draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
type PreviewPdf = {
file: Blob;
@@ -83,13 +83,15 @@ const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.fil
export function ResumePreviewClient({
className,
data,
pageGap,
pageLayout,
pageScale,
pageClassName,
showPageNumbers,
}: ResolvedResumePreviewProps) {
const resumeData = useResumeData();
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
const resumeDocument = useLocalizedResumeDocument(resumeData);
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
@@ -132,7 +134,18 @@ export function ResumePreviewClient({
const visiblePdf = getActivePreviewLayer(previewLayers);
if (!visiblePdf) return <ResumePreviewLoader />;
if (!visiblePdf) {
return (
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
);
}
return (
<div className={cn("grid", className)}>
@@ -0,0 +1,41 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
DEFAULT_PDF_PAGE_SIZE,
getResumePreviewPageCount,
getScaledPreviewPageSize,
ResumePreviewLoader,
} from "./preview.shared";
describe("ResumePreviewLoader", () => {
it("uses the same scaled page dimensions as the preview page", () => {
const pageScale = 1.25;
const expectedSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const { container } = render(<ResumePreviewLoader pageScale={pageScale} />);
const page = container.querySelector("figure > div") as HTMLElement | null;
expect(Number.parseFloat(page?.style.height ?? "0")).toBeCloseTo(expectedSize.height);
expect(Number.parseFloat(page?.style.width ?? "0")).toBeCloseTo(expectedSize.width);
});
it("renders one loading placeholder for each resume layout page", () => {
const pageCount = sampleResumeData.metadata.layout.pages.length;
render(<ResumePreviewLoader pageCount={pageCount} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount);
});
});
describe("getResumePreviewPageCount", () => {
it("uses the resume layout page count when resume data is available", () => {
expect(getResumePreviewPageCount(sampleResumeData)).toBe(sampleResumeData.metadata.layout.pages.length);
});
it("falls back to one page without resume data", () => {
expect(getResumePreviewPageCount()).toBe(1);
});
});
@@ -1,7 +1,10 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
export type ResumePreviewProps = {
className?: string;
data?: ResumeData;
pageGap?: React.CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
@@ -21,6 +24,13 @@ export type PreviewPageSize = {
width: number;
};
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: React.CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
@@ -57,14 +67,48 @@ export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: n
width: pageSize.width * pageScale,
});
export function ResumePreviewLoader() {
return (
<figure className="shrink-0">
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">Loading...</figcaption>
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
<div style={DEFAULT_PDF_PAGE_SIZE} className="rounded-md bg-white">
<Spinner className="size-10" />
</div>
</figure>
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
pageGap = 40,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
}: ResumePreviewLoaderProps) {
const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
return (
<div
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
style={{ "--resume-preview-page-gap": pageGap } as React.CSSProperties}
>
{Array.from({ length: pageCount }, (_, index) => {
const pageNumber = index + 1;
return (
<figure key={pageNumber} className="shrink-0">
{showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {pageCount}
</figcaption>
) : null}
<div
role="img"
aria-label={`Loading resume page ${pageNumber} of ${pageCount}`}
style={pageSize}
className={cn("aspect-page overflow-hidden rounded-md bg-white", pageClassName)}
>
<Spinner className="size-10" />
</div>
</figure>
);
})}
</div>
);
}
+19 -3
View File
@@ -1,7 +1,8 @@
import type { ResumePreviewProps } from "./preview.shared";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared";
import { useResumeData } from "./builder-resume-draft";
import { getResumePreviewPageCount, normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared";
const ResumePreviewClient = lazy(() =>
import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })),
@@ -11,12 +12,27 @@ export type { ResumePreviewProps };
export function ResumePreview(props: ResumePreviewProps) {
const isClient = useIsClient();
const resolvedProps = normalizeResumePreviewProps(props);
const builderResumeData = useResumeData();
const resumeData = resolvedProps.data ?? builderResumeData;
const pageCount = getResumePreviewPageCount(resumeData);
if (!isClient) return null;
return (
<Suspense fallback={<ResumePreviewLoader />}>
<ResumePreviewClient {...normalizeResumePreviewProps(props)} />
<Suspense
fallback={
<ResumePreviewLoader
pageCount={pageCount}
pageClassName={resolvedProps.pageClassName}
pageGap={resolvedProps.pageGap}
pageLayout={resolvedProps.pageLayout}
pageScale={resolvedProps.pageScale}
showPageNumbers={resolvedProps.showPageNumbers}
/>
}
>
<ResumePreviewClient {...resolvedProps} />
</Suspense>
);
}
@@ -45,6 +45,7 @@ export function PublicResumeRoute() {
<>
<div className="mx-auto my-12 flex flex-col items-center gap-12 print:m-0 print:block print:max-w-full print:px-0">
<ResumePreview
data={resume.data}
pageGap="1rem"
pageScale={1.25}
pageLayout="vertical"