From 2e711fd14cf786b827f193e9ad44febf58bbadb4 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 18:31:21 -0700 Subject: [PATCH] fix(web): render thumbnails at displayed pixel density (#3454) * fix(web): render thumbnails at displayed pixel density * fix: cancel obsolete thumbnail raster work --- .../features/resume/preview/pdf-thumbnail.ts | 37 +++-- .../preview/pdfjs-legacy-entrypoints.test.ts | 45 ++++- .../preview/resume-thumbnail.shared.test.ts | 67 ++++---- .../resume/preview/resume-thumbnail.shared.ts | 45 +++-- .../cards/resume-thumbnail.test.tsx | 157 ++++++++++++++++++ .../-components/cards/resume-thumbnail.tsx | 76 +++++++-- tests/e2e/specs/thumbnail-resolution.spec.ts | 96 +++++++++++ 7 files changed, 449 insertions(+), 74 deletions(-) create mode 100644 apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx create mode 100644 tests/e2e/specs/thumbnail-resolution.spec.ts diff --git a/apps/web/src/features/resume/preview/pdf-thumbnail.ts b/apps/web/src/features/resume/preview/pdf-thumbnail.ts index d658ace4e..94c072965 100644 --- a/apps/web/src/features/resume/preview/pdf-thumbnail.ts +++ b/apps/web/src/features/resume/preview/pdf-thumbnail.ts @@ -1,5 +1,6 @@ import type { PreviewPageSize } from "./preview.shared.utils"; -import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared"; +import type { ResumeThumbnailSize } from "./resume-thumbnail.shared"; +import { getResumeThumbnailRenderSize } from "./resume-thumbnail.shared"; const canvasToBlob = (canvas: HTMLCanvasElement) => new Promise((resolve, reject) => { @@ -13,26 +14,35 @@ const canvasToBlob = (canvas: HTMLCanvasElement) => }, "image/png"); }); -export const createPdfFirstPageImageUrl = async (file: Blob) => { +export const createPdfFirstPageImageUrl = async (file: Blob, targetSize: ResumeThumbnailSize, signal?: AbortSignal) => { + signal?.throwIfAborted(); const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs"); GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString(); const arrayBuffer = await file.arrayBuffer(); + signal?.throwIfAborted(); const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) }); let pdfDocument: Awaited | undefined; + let renderTask: { cancel: () => void } | undefined; + let destruction: Promise | undefined; + const destroy = () => (destruction ??= loadingTask.destroy()); + const abort = () => { + renderTask?.cancel(); + void destroy(); + }; + signal?.addEventListener("abort", abort, { once: true }); try { + signal?.throwIfAborted(); pdfDocument = await loadingTask.promise; + signal?.throwIfAborted(); const page = await pdfDocument.getPage(1); try { + signal?.throwIfAborted(); const baseViewport = page.getViewport({ scale: 1 }); const pageSize: PreviewPageSize = { height: baseViewport.height, width: baseViewport.width }; - const renderSize = getResumeThumbnailRenderSize( - pageSize, - RESUME_THUMBNAIL_TARGET_WIDTH, - window.devicePixelRatio || 1, - ); + const renderSize = getResumeThumbnailRenderSize(pageSize, targetSize); const canvas = document.createElement("canvas"); const canvasContext = canvas.getContext("2d"); @@ -43,22 +53,29 @@ export const createPdfFirstPageImageUrl = async (file: Blob) => { canvas.width = renderSize.width; const viewport = page.getViewport({ scale: renderSize.scale }); - const renderTask = page.render({ + const task = page.render({ canvas, canvasContext, viewport, annotationMode: AnnotationMode.DISABLE, background: "white", }); + renderTask = task; - await renderTask.promise; + await task.promise; + signal?.throwIfAborted(); const image = await canvasToBlob(canvas); + signal?.throwIfAborted(); return URL.createObjectURL(image); } finally { page.cleanup(); } + } catch (error) { + if (signal?.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError"); + throw error; } finally { - void loadingTask.destroy(); + signal?.removeEventListener("abort", abort); + void destroy(); } }; diff --git a/apps/web/src/features/resume/preview/pdfjs-legacy-entrypoints.test.ts b/apps/web/src/features/resume/preview/pdfjs-legacy-entrypoints.test.ts index 3e370f3c2..d0fd7d588 100644 --- a/apps/web/src/features/resume/preview/pdfjs-legacy-entrypoints.test.ts +++ b/apps/web/src/features/resume/preview/pdfjs-legacy-entrypoints.test.ts @@ -6,7 +6,7 @@ const pdfjsMock = vi.hoisted(() => { const page = { cleanup: vi.fn(), getViewport: vi.fn(({ scale }: { scale: number }) => ({ height: 200 * scale, width: 100 * scale })), - render: vi.fn(() => ({ promise: Promise.resolve() })), + render: vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() })), }; const pdfDocument = { @@ -15,7 +15,7 @@ const pdfjsMock = vi.hoisted(() => { }; const loadingTask = { - destroy: vi.fn(), + destroy: vi.fn(async () => {}), promise: Promise.resolve(pdfDocument), }; @@ -85,9 +85,9 @@ describe("PDF.js browser entrypoints", () => { const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail"); - await expect(createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }))).resolves.toBe( - "blob:thumbnail", - ); + await expect( + createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }), { width: 600, height: 900 }), + ).resolves.toBe("blob:thumbnail"); expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain( "pdfjs-dist/legacy/build/pdf.worker.min.mjs", @@ -100,4 +100,39 @@ describe("PDF.js browser entrypoints", () => { ); expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1); }); + + it("cancels obsolete thumbnail rasterization and releases the PDF loading task", async () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D); + vi.spyOn(URL, "createObjectURL").mockImplementation(pdfjsMock.createObjectURL); + let rejectRender!: (error: Error) => void; + const promise = new Promise((_resolve, reject) => { + rejectRender = reject; + }); + const cancel = vi.fn(() => rejectRender(new pdfjsMock.legacyModule.RenderingCancelledException("cancelled"))); + pdfjsMock.page.render.mockReturnValueOnce({ promise, cancel }); + const controller = new AbortController(); + const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail"); + const pending = createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal); + const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(pdfjsMock.page.render).toHaveBeenCalledTimes(1)); + try { + controller.abort(); + expect(cancel).toHaveBeenCalledTimes(1); + } finally { + rejectRender(new DOMException("cancelled", "AbortError")); + await rejected; + } + expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1); + expect(pdfjsMock.createObjectURL).not.toHaveBeenCalled(); + }); + + it("does not load a PDF for an already aborted thumbnail request", async () => { + const controller = new AbortController(); + controller.abort(); + const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail"); + await expect( + createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(pdfjsMock.legacyModule.getDocument).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/features/resume/preview/resume-thumbnail.shared.test.ts b/apps/web/src/features/resume/preview/resume-thumbnail.shared.test.ts index 4d94ee937..fb3451798 100644 --- a/apps/web/src/features/resume/preview/resume-thumbnail.shared.test.ts +++ b/apps/web/src/features/resume/preview/resume-thumbnail.shared.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getResumeThumbnailCacheKey, getResumeThumbnailRenderSize, - RESUME_THUMBNAIL_TARGET_WIDTH, + getResumeThumbnailSize, } from "./resume-thumbnail.shared"; describe("getResumeThumbnailCacheKey", () => { @@ -20,45 +20,40 @@ describe("getResumeThumbnailCacheKey", () => { }); describe("getResumeThumbnailRenderSize", () => { - it("uses the default target width when not provided", () => { - const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }); - - expect(size.width).toBe(RESUME_THUMBNAIL_TARGET_WIDTH); - // Scale = 420/800 = 0.525 → height rounds to 630 - expect(size.height).toBe(630); - expect(size.scale).toBeCloseTo(0.525, 5); + it("covers the measured 607 CSS pixel card at DPR 3", () => { + const target = getResumeThumbnailSize({ width: 607, height: 858.47 }, 3); + const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target); + expect(size.width).toBeGreaterThanOrEqual(1821); + expect(size.height).toBeGreaterThanOrEqual(2575); }); - it("scales relative to the provided target width", () => { - const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400); - expect(size.width).toBe(400); - expect(size.scale).toBeCloseTo(0.5, 5); - expect(size.height).toBe(600); + it("fits landscape pages within the measured container", () => { + const size = getResumeThumbnailRenderSize({ width: 1200, height: 800 }, { width: 600, height: 900 }); + expect(size).toEqual({ width: 600, height: 400, scale: 0.5 }); }); - it("clamps pixelRatio to a minimum of 1", () => { - const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 0.5); - const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 1); + it("fits tall unpaginated pages by height rather than allocating their full width", () => { + const size = getResumeThumbnailRenderSize({ width: 600, height: 12000 }, { width: 600, height: 900 }); + expect(size).toEqual({ width: 45, height: 900, scale: 0.075 }); + }); + + it("bounds extreme zoom and viewport sizes without stretching the page", () => { + const target = getResumeThumbnailSize({ width: 5000, height: 7071 }, 8); + const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target); + expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072); + expect(Math.max(size.width, size.height)).toBeLessThanOrEqual(3072); + expect(size.width / size.height).toBeCloseTo(595.28 / 841.89, 2); + }); + + it("enforces the canvas budget when retained width and height came from different size buckets", () => { + const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, { width: 2112, height: 2985 }); + expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072); + }); + + it("groups small fractional layout changes into the same physical size", () => { + const a = getResumeThumbnailSize({ width: 295.5, height: 417.93 }, 2); + const b = getResumeThumbnailSize({ width: 296, height: 418.64 }, 2); expect(a).toEqual(b); - }); - - it("clamps pixelRatio to a maximum of 2", () => { - const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 3); - const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2); - expect(a).toEqual(b); - }); - - it("multiplies width/height by pixelRatio when within bounds", () => { - const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2); - // pageScale = 0.5, outputScale = 2 → width = 800, height = 1200 - expect(size.width).toBe(800); - expect(size.height).toBe(1200); - expect(size.scale).toBeCloseTo(1, 5); - }); - - it("rounds the output dimensions to integers", () => { - const size = getResumeThumbnailRenderSize({ width: 793, height: 1123 }, 421, 1.5); - expect(Number.isInteger(size.width)).toBe(true); - expect(Number.isInteger(size.height)).toBe(true); + expect(a.width).toBeGreaterThanOrEqual(592); }); }); diff --git a/apps/web/src/features/resume/preview/resume-thumbnail.shared.ts b/apps/web/src/features/resume/preview/resume-thumbnail.shared.ts index 89aefcce6..0e6de020b 100644 --- a/apps/web/src/features/resume/preview/resume-thumbnail.shared.ts +++ b/apps/web/src/features/resume/preview/resume-thumbnail.shared.ts @@ -1,26 +1,45 @@ -type PageSize = { +export type ResumeThumbnailSize = { height: number; width: number; }; -export const RESUME_THUMBNAIL_TARGET_WIDTH = 420; -const MAX_THUMBNAIL_PIXEL_RATIO = 2; +// A single-column Grid card is at most 608 CSS px before the sm breakpoint. +// Its A4 container at DPR 3 needs 1824 x 2580 px. This budget accommodates it +// with room for size buckets, while limiting extreme zoom/ultrawide canvases +// to 24 MiB of RGBA pixels and 3072 px on either side. +const MAX_THUMBNAIL_PIXELS = 2048 * 3072; +const MAX_THUMBNAIL_DIMENSION = 3072; +const THUMBNAIL_SIZE_STEP = 64; export const getResumeThumbnailCacheKey = (resumeId: string, updatedAt: Date) => { return `${resumeId}:${updatedAt.getTime()}`; }; -export const getResumeThumbnailRenderSize = ( - pageSize: PageSize, - targetWidth = RESUME_THUMBNAIL_TARGET_WIDTH, - pixelRatio = 1, -) => { - const outputScale = Math.min(Math.max(pixelRatio, 1), MAX_THUMBNAIL_PIXEL_RATIO); - const pageScale = targetWidth / pageSize.width; +const limitThumbnailSize = ({ width, height }: ResumeThumbnailSize): ResumeThumbnailSize => { + const scale = Math.min( + 1, + MAX_THUMBNAIL_DIMENSION / Math.max(width, height), + Math.sqrt(MAX_THUMBNAIL_PIXELS / (width * height)), + ); + return { width: Math.floor(width * scale), height: Math.floor(height * scale) }; +}; + +export const getResumeThumbnailSize = (container: ResumeThumbnailSize, pixelRatio: number): ResumeThumbnailSize => { + const ratio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1; + return limitThumbnailSize({ + width: Math.ceil((container.width * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP, + height: Math.ceil((container.height * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP, + }); +}; + +export const getResumeThumbnailRenderSize = (pageSize: ResumeThumbnailSize, target: ResumeThumbnailSize) => { + const bounds = limitThumbnailSize(target); + // Match background-size: contain, including landscape and unpaginated PDFs. + const scale = Math.min(bounds.width / pageSize.width, bounds.height / pageSize.height); return { - height: Math.round(pageSize.height * pageScale * outputScale), - scale: pageScale * outputScale, - width: Math.round(targetWidth * outputScale), + height: Math.min(bounds.height, Math.ceil(pageSize.height * scale)), + scale, + width: Math.min(bounds.width, Math.ceil(pageSize.width * scale)), }; }; diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx new file mode 100644 index 000000000..f2f8dc1f4 --- /dev/null +++ b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import type { ComponentProps } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { ResumeThumbnail } from "./resume-thumbnail"; + +const mocks = vi.hoisted(() => ({ + inView: true, + toPdf: vi.fn(async () => new Blob(["pdf"])), + toImage: vi.fn(async (_pdf: Blob, size?: { width: number; height: number }) => `blob:${size?.width}x${size?.height}`), +})); + +vi.mock("motion/react", () => ({ useInView: () => mocks.inView })); +vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob: mocks.toPdf })); +vi.mock("@/features/resume/preview/pdf-thumbnail", () => ({ createPdfFirstPageImageUrl: mocks.toImage })); +vi.mock("@/libs/orpc/client", () => ({ + orpc: { + resume: { + getById: { + queryOptions: () => ({ + queryKey: ["resume-data"], + queryFn: async () => ({ data: sampleResumeData }), + }), + }, + }, + }, +})); + +const resume: ComponentProps["resume"] = { + id: "thumbnail-test", + name: "Resume", + slug: "resume", + tags: [], + isLocked: false, + isPublic: false, + showDownloadButtons: true, + createdAt: new Date(0), + updatedAt: new Date(0), +}; +let resize: () => void; +let media: EventTarget; +let width = 270; +let height = 382; + +beforeEach(() => { + mocks.inView = true; + mocks.toPdf.mockClear(); + mocks.toImage.mockClear(); + width = 270; + height = 382; + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => width); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => height); + vi.stubGlobal("devicePixelRatio", 2); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect() {} + }, + ); + vi.spyOn(window, "matchMedia").mockImplementation(() => { + media = new EventTarget(); + return media as MediaQueryList; + }); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function setup() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, + }); + const ui = () => ( + + + + ); + const result = render(ui()); + const image = () => result.container.querySelector("[style*='background-image']")?.style.backgroundImage; + return { ...result, image, refresh: () => result.rerender(ui()) }; +} + +it("upgrades a cached image after growth and keeps it visible until replacement is ready", async () => { + const view = setup(); + await waitFor(() => expect(view.image()).toContain("blob:576x768")); + let finish!: (url: string) => void; + mocks.toImage.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + act(() => { + width = 607; + height = 859; + resize(); + }); + await waitFor(() => expect(mocks.toImage).toHaveBeenCalledTimes(2)); + expect(view.image()).toContain("blob:576x768"); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + expect(mocks.toImage.mock.calls[1]?.[1]).toEqual({ width: 1216, height: 1728 }); + await act(async () => finish("blob:1216x1728")); + await waitFor(() => expect(view.image()).toContain("blob:1216x1728")); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:576x768"); + act(() => { + width = 295; + height = 418; + resize(); + }); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(view.image()).toContain("blob:1216x1728"); + expect(mocks.toImage).toHaveBeenCalledTimes(2); + view.unmount(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:1216x1728"); +}); + +it("upgrades after a DPR change even when CSS dimensions do not change", async () => { + const view = setup(); + await waitFor(() => expect(view.image()).toContain("blob:576x768")); + act(() => { + vi.stubGlobal("devicePixelRatio", 3); + media.dispatchEvent(new Event("change")); + }); + await waitFor(() => expect(view.image()).toContain("blob:832x1152")); +}); + +it("does not generate thumbnails before an offscreen card enters view", async () => { + mocks.inView = false; + const view = setup(); + await act(async () => {}); + expect(view.image()).toBeUndefined(); + expect(mocks.toPdf).not.toHaveBeenCalled(); + mocks.inView = true; + view.refresh(); + await waitFor(() => expect(view.image()).toContain("blob:576x768")); + mocks.inView = false; + view.refresh(); + act(() => { + width = 607; + height = 859; + }); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(mocks.toImage).toHaveBeenCalledTimes(1); + mocks.inView = true; + view.refresh(); + await waitFor(() => expect(view.image()).toContain("blob:1216x1728")); +}); diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx index c54b7e4dd..3ed179a1e 100644 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx +++ b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx @@ -1,14 +1,16 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { RefObject } from "react"; +import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared"; import type { RouterOutput } from "@/libs/orpc/client"; import { FileTextIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { useInView } from "motion/react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Spinner } from "@reactive-resume/ui/components/spinner"; import { cn } from "@reactive-resume/utils/style"; import { createResumePdfBlob } from "@/features/resume/export/pdf-document"; import { createPdfFirstPageImageUrl } from "@/features/resume/preview/pdf-thumbnail"; -import { getResumeThumbnailCacheKey } from "@/features/resume/preview/resume-thumbnail.shared"; +import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared"; import { orpc } from "@/libs/orpc/client"; type ResumeListItem = RouterOutput["resume"]["list"][number]; @@ -24,11 +26,11 @@ const throwIfAborted = (signal: AbortSignal) => { if (signal.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError"); }; -const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) => { +const createResumeThumbnailUrl = async (data: ResumeData, size: ResumeThumbnailSize, signal: AbortSignal) => { const pdf = await createResumePdfBlob(data); throwIfAborted(signal); - const url = await createPdfFirstPageImageUrl(pdf); + const url = await createPdfFirstPageImageUrl(pdf, size, signal); if (signal.aborted) { URL.revokeObjectURL(url); @@ -38,18 +40,69 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) = return url; }; -function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState { +function useThumbnailSize(containerRef: RefObject, enabled: boolean) { + const [size, setSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const container = containerRef.current; + if (!enabled || !container) return; + let measured = { width: container.clientWidth, height: container.clientHeight }; + let timeout: ReturnType; + let media: MediaQueryList; + const syncSize = () => { + clearTimeout(timeout); + // Coalesce sidebar animations and continuous viewport resizing. + timeout = setTimeout(() => { + if (measured.width <= 0 || measured.height <= 0) return; + const next = getResumeThumbnailSize(measured, window.devicePixelRatio); + setSize((current) => { + if (current.width >= next.width && current.height >= next.height) return current; + return { width: Math.max(current.width, next.width), height: Math.max(current.height, next.height) }; + }); + }, 150); + }; + const watchPixelRatio = () => { + media?.removeEventListener("change", watchPixelRatio); + media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + media.addEventListener("change", watchPixelRatio); + syncSize(); + }; + const observer = new ResizeObserver((entries) => { + measured = entries?.[0]?.contentRect ?? { width: container.clientWidth, height: container.clientHeight }; + syncSize(); + }); + observer.observe(container); + watchPixelRatio(); + + return () => { + clearTimeout(timeout); + observer.disconnect(); + media.removeEventListener("change", watchPixelRatio); + }; + }, [containerRef, enabled]); + + return size; +} + +function useResumeThumbnail( + data: ResumeData | undefined, + cacheKey: string, + size: ResumeThumbnailSize, + enabled: boolean, +): ThumbnailState { const { data: thumbnailData, error: thumbnailError, isError: thumbnailIsError, } = useQuery({ - queryKey: ["resume-thumbnail", cacheKey], + queryKey: ["resume-thumbnail", cacheKey, size.width, size.height], queryFn: ({ signal }) => { if (!data) throw new Error("Resume data is required to generate a thumbnail."); - return createResumeThumbnailUrl(data, signal); + return createResumeThumbnailUrl(data, size, signal); }, - enabled: Boolean(data && cacheKey), + enabled: Boolean(enabled && data && size.width && size.height), + placeholderData: (previous, query) => (query?.queryKey[1] === cacheKey ? previous : undefined), + staleTime: Number.POSITIVE_INFINITY, gcTime: 0, }); @@ -74,14 +127,17 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) { const containerRef = useRef(null); - const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true }); + const isInView = useInView(containerRef, { amount: 0.1, margin: "240px" }); + const size = useThumbnailSize(containerRef, isInView); const { data: resumeData, isError: resumeIsError } = useQuery({ ...orpc.resume.getById.queryOptions({ input: { id: resume.id } }), enabled: isInView, }); const thumbnail = useResumeThumbnail( resumeData?.data, - isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined, + getResumeThumbnailCacheKey(resume.id, resume.updatedAt), + size, + isInView, ); const hasFailed = resumeIsError || thumbnail.status === "error"; diff --git a/tests/e2e/specs/thumbnail-resolution.spec.ts b/tests/e2e/specs/thumbnail-resolution.spec.ts new file mode 100644 index 000000000..4ddc203ca --- /dev/null +++ b/tests/e2e/specs/thumbnail-resolution.spec.ts @@ -0,0 +1,96 @@ +import type { Page } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; +import { createSampleResumeFromDashboard } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +async function measureThumbnail(page: Page) { + const image = page.locator('a[href^="/builder/"] [style*="background-image"]').first(); + await expect(image).toBeVisible({ timeout: 60_000 }); + return image.evaluate(async (element) => { + const style = getComputedStyle(element); + const url = style.backgroundImage.slice(5, -2); + const blob = await (await fetch(url)).blob(); + const bitmap = await createImageBitmap(blob); + const rect = element.getBoundingClientRect(); + const scale = Math.min(rect.width / bitmap.width, rect.height / bitmap.height); + const result = { + url, + viewport: innerWidth, + dpr: devicePixelRatio, + cssWidth: rect.width, + cssHeight: rect.height, + pngWidth: bitmap.width, + pngHeight: bitmap.height, + coverage: 1 / (scale * devicePixelRatio), + }; + bitmap.close(); + return result; + }); +} + +async function expectSharpThumbnail(page: Page) { + await page.mouse.move(0, 0); + await expect + .poll(async () => (await measureThumbnail(page)).coverage, { timeout: 60_000 }) + .toBeGreaterThanOrEqual(0.99); + return measureThumbnail(page); +} + +test("dashboard thumbnails cover physical pixels in fresh and cached Grid and Compact views", async ({ + authPage, + browser, +}, info) => { + test.setTimeout(180_000); + await createSampleResumeFromDashboard(authPage, info); + const storageState = await authPage.context().storageState(); + const measurements: unknown[] = []; + for (const deviceScaleFactor of [1, 2, 3]) { + const context = await browser.newContext({ + baseURL: String(info.project.use.baseURL), + storageState, + deviceScaleFactor, + viewport: { width: 1440, height: 1000 }, + }); + try { + const page = await context.newPage(); + await page.goto("/dashboard/resumes?view=grid"); + const desktop = await expectSharpThumbnail(page); + measurements.push({ scenario: "fresh-desktop-grid", ...desktop }); + // Immediately below sm, one Grid column is 607 CSS px wide. + await page.setViewportSize({ width: 639, height: 1000 }); + const grown = await expectSharpThumbnail(page); + expect(grown.pngWidth).toBeGreaterThan(desktop.pngWidth); + measurements.push({ scenario: "grown-cached-grid", ...grown }); + await page.screenshot({ path: info.outputPath(`grid-dpr${deviceScaleFactor}.png`) }); + await page.getByRole("tab", { name: "Compact", exact: true }).click(); + const compact = await expectSharpThumbnail(page); + expect(compact.url).toBe(grown.url); + measurements.push({ scenario: "cached-compact", ...compact }); + await page.reload(); + measurements.push({ scenario: "fresh-compact", ...(await expectSharpThumbnail(page)) }); + await page.getByRole("tab", { name: "Grid", exact: true }).click(); + measurements.push({ scenario: "compact-to-grid", ...(await expectSharpThumbnail(page)) }); + await page.reload(); + measurements.push({ scenario: "fresh-mobile-grid", ...(await expectSharpThumbnail(page)) }); + await page.setViewportSize({ width: 390, height: 1000 }); + const phone = await expectSharpThumbnail(page); + measurements.push({ scenario: "phone-grid", ...phone }); + if (deviceScaleFactor === 1) { + const cdp = await context.newCDPSession(page); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 1000, + deviceScaleFactor: 3, + mobile: false, + }); + await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(3); + const higherDpr = await expectSharpThumbnail(page); + expect(higherDpr.pngWidth).toBeGreaterThan(phone.pngWidth); + measurements.push({ scenario: "changed-dpr-grid", ...higherDpr }); + } + } finally { + await context.close(); + } + } + await writeFile(info.outputPath("measurements.json"), JSON.stringify(measurements, null, 2)); +});