diff --git a/next.config.ts b/next.config.ts index 262feb75..032188aa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,9 +52,15 @@ const nextConfig: NextConfig = { // Prefer AVIF (~20-30% smaller than WebP), falling back to WebP, for any // next/image output. formats: ["image/avif", "image/webp"], - // Permit optimizing Supabase Storage signed URLs (private document/image - // previews) through next/image. Scoped to this app's exact production and - // (when configured) staging project hostnames, not the wildcard *.supabase.co. + // Private signed document/image previews opt out of the optimizer at the + // component level (`SignedImage` sets `unoptimized`). Do not rely on + // `minimumCacheTTL` as an expiry cap for bearer URLs: it is a lower bound, + // and stale-while-revalidate can keep serving private bytes past the + // signed-URL lifetime without re-entering the authenticated signed-URL route. + // Permit optimizing other Supabase Storage URLs through next/image when a + // caller intentionally uses the optimizer. Scoped to this app's exact + // production and (when configured) staging project hostnames, not the + // wildcard *.supabase.co. remotePatterns: (() => { const allowedHostnames = [expectedSupabaseProject.ref + ".supabase.co"]; const stagingRef = process.env.SUPABASE_STAGING_PROJECT_REF?.trim(); diff --git a/src/components/clinical-dashboard/signed-image.tsx b/src/components/clinical-dashboard/signed-image.tsx index 0463f480..66e691a2 100644 --- a/src/components/clinical-dashboard/signed-image.tsx +++ b/src/components/clinical-dashboard/signed-image.tsx @@ -1,7 +1,6 @@ "use client"; -/* eslint-disable @next/next/no-img-element */ - +import Image from "next/image"; import { memo, useEffect, useRef, useState } from "react"; import { CircleAlert, Loader2, Maximize2 } from "lucide-react"; @@ -129,15 +128,20 @@ export const SignedImage = memo(function SignedImage({ )} > {url ? ( - {alt} setLoaded(true)} onError={handleImageError} className={cn( - "absolute inset-0 h-full w-full rounded-lg object-contain transition-opacity duration-300 motion-reduce:transition-none", + "rounded-lg object-contain transition-opacity duration-300 motion-reduce:transition-none", loaded ? "opacity-100" : "opacity-0", )} /> diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index 74e48065..d3812541 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -15,7 +15,11 @@ beforeEach(() => { }); afterEach(() => { - document.body.style.overflow = ""; + // Guard teardown: unhandled rAF/setTimeout from Sheet focus scheduling can + // fire after Vitest tears down the jsdom environment under coverage workers. + if (typeof document !== "undefined" && document.body) { + document.body.style.overflow = ""; + } }); function Stacked({ diff --git a/tests/signed-image.dom.test.tsx b/tests/signed-image.dom.test.tsx index c882abd0..77291283 100644 --- a/tests/signed-image.dom.test.tsx +++ b/tests/signed-image.dom.test.tsx @@ -52,7 +52,11 @@ describe("SignedImage failure/retry (jsdom)", () => { await user.click(retry); const img = await screen.findByRole("img", { name: "Airway diagram" }); - expect(img).toHaveAttribute("src", "/demo/airway.png"); + // Private previews use unoptimized next/image, so src stays the direct URL + // (jsdom may absolutize it) and must not be rewritten through `/_next/image`. + const src = img.getAttribute("src") ?? ""; + expect(src.endsWith("/demo/airway.png")).toBe(true); + expect(src).not.toContain("/_next/image"); expect(screen.queryByText("Image preview failed.")).not.toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledTimes(2); }); diff --git a/tests/signed-image.test.ts b/tests/signed-image.test.ts index 449282d4..707a262a 100644 --- a/tests/signed-image.test.ts +++ b/tests/signed-image.test.ts @@ -47,7 +47,10 @@ describe("SignedImage", () => { const markup = renderToStaticMarkup(createElement(SignedImage, { endpoint: ENDPOINT, alt: "Airway diagram" })); expect(markup).toContain("