From bac7df072b6d756afa9567f70b86394878a66c8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:19:54 +0000 Subject: [PATCH 1/6] perf(images): optimize private signed previews through next/image (#014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignedImage backed DocumentImage + SourceImage with a raw that loaded the full-resolution Supabase scan just to fill a small 4:3 thumbnail. Route it through next/image so the optimizer resizes to preview dimensions and transcodes to AVIF/WebP, realizing the images config #1024 added (which was inert until now). - fill + sizes="(max-width: 768px) 92vw, 320px" matches the fixed-aspect frame and caps the srcset to preview widths. - Deferred IntersectionObserver load, fade-in on decode, and retry-on-error are all preserved; the lightbox keeps its own raw for full-resolution zoom. - The signed ?token= query is optimizable because next.config images.remotePatterns pins the project host without a search restriction; a non-allow-listed host is rejected (400). Measured on a demo preview: 79,558 B PNG -> 9,966 B AVIF (~87% smaller); full-resolution clinical scans stand to save more. Governance: the optimizer fetches the signed URL server-side and caches the optimized bytes app-side (minimumCacheTTL); documented in the PR clinical-governance preflight. No auth/access-control change — the signed URL remains the capability and the client still obtains it through the auth-gated /signed-url endpoint. Updated the two signed-image tests to assert the next/image-optimized src (/_next/image?url=…) instead of a raw src. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UbhUVWVJRwDibC2YtJ6aRX --- .../clinical-dashboard/signed-image.tsx | 20 +++++++++++++------ tests/signed-image.dom.test.tsx | 4 +++- tests/signed-image.test.ts | 4 +++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/signed-image.tsx b/src/components/clinical-dashboard/signed-image.tsx index 0463f4806..9218606c6 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,24 @@ 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/signed-image.dom.test.tsx b/tests/signed-image.dom.test.tsx index c882abd0d..bfb9ccf0e 100644 --- a/tests/signed-image.dom.test.tsx +++ b/tests/signed-image.dom.test.tsx @@ -52,7 +52,9 @@ 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"); + // next/image serves the recovered URL through the optimizer, so assert the src + // encodes the freshly fetched signed URL rather than matching it raw. + expect(img.getAttribute("src")).toContain(`url=${encodeURIComponent("/demo/airway.png")}`); 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 449282d4b..4c501d0aa 100644 --- a/tests/signed-image.test.ts +++ b/tests/signed-image.test.ts @@ -47,7 +47,9 @@ describe("SignedImage", () => { const markup = renderToStaticMarkup(createElement(SignedImage, { endpoint: ENDPOINT, alt: "Airway diagram" })); expect(markup).toContain(" Date: Wed, 22 Jul 2026 06:45:06 +0000 Subject: [PATCH 2/6] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- next.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/next.config.ts b/next.config.ts index 262feb75f..457705dea 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,6 +52,10 @@ const nextConfig: NextConfig = { // Prefer AVIF (~20-30% smaller than WebP), falling back to WebP, for any // next/image output. formats: ["image/avif", "image/webp"], + // Cache optimized images for 5 minutes; must stay below the 600-second signed-URL + // TTL defined in src/app/api/{images,documents}/[id]/signed-url/route.ts so the + // optimizer does not serve cached private previews after authorization expires. + minimumCacheTTL: 300, // 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. From 41c9e2b00956102fc9f03d5c4986b838cb45131a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:51:59 +0000 Subject: [PATCH 3/6] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- next.config.ts | 9 ++++++--- src/components/clinical-dashboard/signed-image.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/next.config.ts b/next.config.ts index 457705dea..ecdad689f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,9 +52,12 @@ const nextConfig: NextConfig = { // Prefer AVIF (~20-30% smaller than WebP), falling back to WebP, for any // next/image output. formats: ["image/avif", "image/webp"], - // Cache optimized images for 5 minutes; must stay below the 600-second signed-URL - // TTL defined in src/app/api/{images,documents}/[id]/signed-url/route.ts so the - // optimizer does not serve cached private previews after authorization expires. + // Cache optimized images for at least 5 minutes. This is a floor on cache duration, + // not a ceiling; the actual cache TTL is max(minimumCacheTTL, upstream Cache-Control). + // Authorization for private previews is scoped at signed-URL issuance (owner-scoped + // token) and the optimizer caches by the full URL including the token, so a new token + // (issued by the client-side cache before the prior one expires) fetches fresh without + // receiving an authorization boost from the optimizer's cache. minimumCacheTTL: 300, // Permit optimizing Supabase Storage signed URLs (private document/image // previews) through next/image. Scoped to this app's exact production and diff --git a/src/components/clinical-dashboard/signed-image.tsx b/src/components/clinical-dashboard/signed-image.tsx index 9218606c6..4588f6ece 100644 --- a/src/components/clinical-dashboard/signed-image.tsx +++ b/src/components/clinical-dashboard/signed-image.tsx @@ -134,9 +134,10 @@ export const SignedImage = memo(function SignedImage({ // frame (a `relative` parent) and `sizes` caps the srcset to preview widths // so the browser never fetches a full-width variant. The signed `?token=` // query is optimizable because next.config `images.remotePatterns` pins the - // project host without a `search` restriction. Governance note: the optimizer - // fetches the signed URL server-side and caches the optimized bytes app-side - // (minimumCacheTTL) — called out in the PR clinical-governance preflight. + // project host without a `search` restriction. Authorization: the signed URL's + // token is owner-scoped at issuance; the optimizer caches by the full URL + // (including token), so a new token fetches fresh. The client-side cache + // (signed-url-cache.ts) issues new tokens before expiry. {alt} Date: Wed, 22 Jul 2026 16:52:25 +0800 Subject: [PATCH 4/6] fix(images): keep private signed previews off optimizer cache Drop minimumCacheTTL as a false expiry cap and mark SignedImage unoptimized so bearer URLs never enter the unauthenticated /_next/image cache. Update tests to assert direct src values. Co-authored-by: Cursor --- next.config.ts | 19 +++++++++---------- .../clinical-dashboard/signed-image.tsx | 15 +++++---------- tests/signed-image.dom.test.tsx | 7 ++++--- tests/signed-image.test.ts | 7 ++++--- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/next.config.ts b/next.config.ts index ecdad689f..032188aa7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,16 +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"], - // Cache optimized images for at least 5 minutes. This is a floor on cache duration, - // not a ceiling; the actual cache TTL is max(minimumCacheTTL, upstream Cache-Control). - // Authorization for private previews is scoped at signed-URL issuance (owner-scoped - // token) and the optimizer caches by the full URL including the token, so a new token - // (issued by the client-side cache before the prior one expires) fetches fresh without - // receiving an authorization boost from the optimizer's cache. - minimumCacheTTL: 300, - // 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 4588f6ece..66e691a22 100644 --- a/src/components/clinical-dashboard/signed-image.tsx +++ b/src/components/clinical-dashboard/signed-image.tsx @@ -128,21 +128,16 @@ export const SignedImage = memo(function SignedImage({ )} > {url ? ( - // Route the private preview through next/image so the optimizer resizes - // the (often multi-MB, full-resolution) Supabase scan down to this small - // thumbnail and transcodes it to AVIF/WebP. `fill` matches the fixed-aspect - // frame (a `relative` parent) and `sizes` caps the srcset to preview widths - // so the browser never fetches a full-width variant. The signed `?token=` - // query is optimizable because next.config `images.remotePatterns` pins the - // project host without a `search` restriction. Authorization: the signed URL's - // token is owner-scoped at issuance; the optimizer caches by the full URL - // (including token), so a new token fetches fresh. The client-side cache - // (signed-url-cache.ts) issues new tokens before expiry. + // Keep next/image for fill/layout/sizes, but mark private signed previews + // `unoptimized` so bearer URLs never enter the unauthenticated + // `/_next/image` optimizer cache (stale-while-revalidate can outlive the + // signed token). Authorization stays on `/api/.../signed-url` issuance. {alt} setLoaded(true)} onError={handleImageError} className={cn( diff --git a/tests/signed-image.dom.test.tsx b/tests/signed-image.dom.test.tsx index bfb9ccf0e..cbc0509d4 100644 --- a/tests/signed-image.dom.test.tsx +++ b/tests/signed-image.dom.test.tsx @@ -52,9 +52,10 @@ describe("SignedImage failure/retry (jsdom)", () => { await user.click(retry); const img = await screen.findByRole("img", { name: "Airway diagram" }); - // next/image serves the recovered URL through the optimizer, so assert the src - // encodes the freshly fetched signed URL rather than matching it raw. - expect(img.getAttribute("src")).toContain(`url=${encodeURIComponent("/demo/airway.png")}`); + // Private previews use unoptimized next/image, so src stays the direct URL + // and must not be rewritten through `/_next/image`. + expect(img.getAttribute("src")).toBe("/demo/airway.png"); + expect(img.getAttribute("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 4c501d0aa..707a262af 100644 --- a/tests/signed-image.test.ts +++ b/tests/signed-image.test.ts @@ -47,9 +47,10 @@ describe("SignedImage", () => { const markup = renderToStaticMarkup(createElement(SignedImage, { endpoint: ENDPOINT, alt: "Airway diagram" })); expect(markup).toContain(" Date: Wed, 22 Jul 2026 17:03:43 +0800 Subject: [PATCH 5/6] test(images): allow jsdom-absolutized direct signed preview src Co-authored-by: Cursor --- tests/signed-image.dom.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/signed-image.dom.test.tsx b/tests/signed-image.dom.test.tsx index cbc0509d4..77291283b 100644 --- a/tests/signed-image.dom.test.tsx +++ b/tests/signed-image.dom.test.tsx @@ -53,9 +53,10 @@ describe("SignedImage failure/retry (jsdom)", () => { const img = await screen.findByRole("img", { name: "Airway diagram" }); // Private previews use unoptimized next/image, so src stays the direct URL - // and must not be rewritten through `/_next/image`. - expect(img.getAttribute("src")).toBe("/demo/airway.png"); - expect(img.getAttribute("src")).not.toContain("/_next/image"); + // (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); }); From b7af8697524160ddcf008dcf125ca2f49c3c4cd4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:18:22 +0800 Subject: [PATCH 6/6] test(sheet): guard overflow cleanup after jsdom teardown Co-authored-by: Cursor --- tests/sheet.dom.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index 74e480653..d38125411 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({