diff --git a/apps/web/src/dialogs/resume/template/gallery.tsx b/apps/web/src/dialogs/resume/template/gallery.tsx index 8d26eba1b..efc91a677 100644 --- a/apps/web/src/dialogs/resume/template/gallery.tsx +++ b/apps/web/src/dialogs/resume/template/gallery.tsx @@ -3,14 +3,12 @@ import type { Template } from "@reactive-resume/schema/templates"; import type { DialogProps } from "@/dialogs/store"; import type { TemplateMetadata } from "./data"; import { t } from "@lingui/core/macro"; -import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { SlideshowIcon } from "@phosphor-icons/react"; import { lazy, Suspense } from "react"; import { toast } from "sonner"; import { Badge } from "@reactive-resume/ui/components/badge"; import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@reactive-resume/ui/components/dialog"; -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card"; import { ScrollArea } from "@reactive-resume/ui/components/scroll-area"; import { cn } from "@reactive-resume/utils/style"; import { CometCard } from "@/components/animation/comet-card"; @@ -18,9 +16,8 @@ import { useDialogStore } from "@/dialogs/store"; import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; import { templates } from "./data"; -// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens — mirrors the -// sidebar template card. The hover card mounts its content only when open, so at most one tile renders -// live at a time; `TemplateLivePreview` caches per (data, template) and cancels on close. +// Lazy so the browser PDF pipeline (pdf.js) loads only when the gallery opens. +// All visible tiles share the same lazy chunk; only one PDF renders at a time via the shared serial queue. const TemplateLivePreview = lazy(() => import("@/features/resume/preview/template-live-preview").then((module) => ({ default: module.TemplateLivePreview, @@ -34,7 +31,6 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) const updateResumeData = useUpdateResumeData(); function onSelectTemplate(template: Template) { - // Snapshot the only field this switch mutates so the undo action fully reverts it. const previousTemplate = resume.data.metadata.template; if (template === previousTemplate) { closeDialog(); @@ -60,7 +56,7 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) } return ( - + @@ -75,8 +71,8 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) - -
+ +
{Object.entries(templates).map(([template, metadata]) => ( - - onSelect(id)} - className={cn( - "relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none", - isActive && "ring-2 ring-ring ring-offset-4 ring-offset-background", - )} - > - {metadata.name} - - } - /> + + -
- {metadata.name} +
+ {metadata.name} +
+ + {metadata.tags.length > 0 && ( +
+ {metadata.tags + .sort((a, b) => a.localeCompare(b)) + .map((tag) => ( + + {tag} + + ))}
- - - {/* Live peek of the user's own data through this template. Static JPG tile stays the cheap default; - this renders on demand only while the card is open, one tile at a time. */} - - {metadata.name} -
- } - > - - - -
-

{metadata.name}

-

{i18n.t(metadata.description)}

-
- - {metadata.tags.length > 0 && ( -
- {metadata.tags - .sort((a, b) => a.localeCompare(b)) - .map((tag) => ( - - {tag} - - ))} -
- )} - -
- + )} +
); } diff --git a/apps/web/src/features/resume/preview/template-live-preview.tsx b/apps/web/src/features/resume/preview/template-live-preview.tsx index 9ade6a3c1..d844c0f5b 100644 --- a/apps/web/src/features/resume/preview/template-live-preview.tsx +++ b/apps/web/src/features/resume/preview/template-live-preview.tsx @@ -1,6 +1,6 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { Template } from "@reactive-resume/schema/templates"; -import { useEffect, useState } 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"; @@ -8,8 +8,8 @@ import { createPdfFirstPageImageUrl } from "./pdf-thumbnail"; // Bounded FIFO cache of generated object URLs keyed by (data, template) reference. Evicted entries are // revoked so blobs don't leak; stale-data entries (a new `data` object after an edit) age out via the cap. -// ponytail: cap-8 linear scan — trivial for a hover preview; only one card renders at a time. -const PREVIEW_CACHE_LIMIT = 8; +// ponytail: cap-24 linear scan — gallery can show ~20 templates at once. +const PREVIEW_CACHE_LIMIT = 24; type PreviewCacheEntry = { data: ResumeData; template: Template; url: string }; const previewCache: PreviewCacheEntry[] = []; @@ -24,10 +24,10 @@ const setCachedPreview = (data: ResumeData, template: Template, url: string) => } }; -// Single-flight render pipeline shared across every mounted instance: renders run one-at-a-time on the -// main thread (serialized through `renderQueue`), and only the latest requested render commits its result -// (`latestRenderRequestId`), so rapid hovers between templates discard superseded work instead of stacking. -let latestRenderRequestId = 0; +// Serial render pipeline shared across all instances: PDFs generate one-at-a-time on the main thread +// (serialized through `renderQueue`) so the gallery doesn't spike CPU/memory when all tiles mount at once. +// Per-instance `latestRenderRequestId` ref (not global) ensures that if a component re-renders with new +// props it discards its own superseded work without cancelling renders for other tiles. let renderQueue: Promise = Promise.resolve(); type TemplateLivePreviewProps = { @@ -47,6 +47,8 @@ type TemplateLivePreviewProps = { export function TemplateLivePreview({ alt, className, data, fallbackSrc, template }: TemplateLivePreviewProps) { const [imageUrl, setImageUrl] = useState(() => getCachedPreview(data, template) ?? null); const [hasError, setHasError] = useState(false); + // Per-instance counter: only the latest render request for THIS tile commits its result. + const latestRequestId = useRef(0); useEffect(() => { const cached = getCachedPreview(data, template); @@ -56,10 +58,10 @@ export function TemplateLivePreview({ alt, className, data, fallbackSrc, templat } let cancelled = false; - const requestId = ++latestRenderRequestId; + const requestId = ++latestRequestId.current; renderQueue = renderQueue.then(async () => { - if (cancelled || requestId !== latestRenderRequestId) return; + if (cancelled || requestId !== latestRequestId.current) return; // Another instance may have cached this exact preview while we were queued. const existing = getCachedPreview(data, template); @@ -72,7 +74,7 @@ export function TemplateLivePreview({ alt, className, data, fallbackSrc, templat const blob = await createResumePdfBlob(data, template); const url = await createPdfFirstPageImageUrl(blob); - if (cancelled || requestId !== latestRenderRequestId) { + if (cancelled || requestId !== latestRequestId.current) { URL.revokeObjectURL(url); return; }