From bbb3da52080c35e69e734e605c09ed5c29b33de8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:41:21 +0000 Subject: [PATCH] fix(local): Decode raw envelope attachments only when previewed Raw envelope overviews decoded every attachment up front, including base64 images and video, and created a Blob URL for each one. Attachments there now start collapsed, show the filename, type and header length, and decode only when their preview is opened. A single mounted toggle keeps keyboard focus across expand and collapse. Selected attachment items keep rendering their preview immediately. Mirrors the lazy rendering in getsentry/spotlight#1358. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- apps/local/README.md | 3 +- .../src/components/attachment-detail.tsx | 92 ++++++++++++++----- .../components/semantic-inspection.test.tsx | 39 +++++++- 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/apps/local/README.md b/apps/local/README.md index 2a362420f..acc8d5dca 100644 --- a/apps/local/README.md +++ b/apps/local/README.md @@ -75,7 +75,8 @@ receiver's envelope retention limit when replaying a large corpus. by session/client reports. Feedback links to associated errors in the capture. - Attachments show safe text/JSON/image/video previews and downloads. Captured HTML, SVG and code remain inert; the viewer never executes them or fetches - captured resource URLs. + captured resource URLs. Attachments inside a raw envelope stay collapsed and + are decoded only when you show their preview. Use severity/logger filters and sorting in event/log views. The Columns control chooses which metadata appears beside each message. In a trace, search spans, diff --git a/apps/local/src/components/attachment-detail.tsx b/apps/local/src/components/attachment-detail.tsx index 138777db1..3e3381b8b 100644 --- a/apps/local/src/components/attachment-detail.tsx +++ b/apps/local/src/components/attachment-detail.tsx @@ -1,20 +1,34 @@ import { attachmentBytes } from "@/lib/attachments.ts"; -import { useEffect, useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import type { LocalFeedItem } from "@/lib/spotlight.ts"; import { text } from "@/lib/inspection.ts"; import { JsonView } from "./json-view.tsx"; -export function AttachmentDetail({ item }: { item: LocalFeedItem }) { +/** + * Render one attachment. Collapsible attachments stay undecoded until the user + * opens their preview, so envelopes with many or large files render cheaply. + */ +export function AttachmentDetail({ + item, + collapsible = false, +}: { + item: LocalFeedItem; + collapsible?: boolean; +}) { + const [expanded, setExpanded] = useState(!collapsible); const [url, setUrl] = useState(); const name = text(item.itemHeader?.filename) ?? "Attachment"; - const attachment = attachmentBytes(item); + const attachment = useMemo( + () => (expanded ? attachmentBytes(item) : undefined), + [expanded, item], + ); useEffect(() => { - const decoded = attachmentBytes(item); - if (!decoded || typeof URL.createObjectURL !== "function") return; + if (!attachment || typeof URL.createObjectURL !== "function") return; // Downloads stay inert even when the captured MIME type is HTML or SVG. const objectUrl = URL.createObjectURL( - new Blob([decoded.bytes], { - type: decoded.downloadType, + new Blob([attachment.bytes], { + type: attachment.downloadType, }), ); // Object URLs are browser resources: create after commit and revoke on cleanup. @@ -24,33 +38,61 @@ export function AttachmentDetail({ item }: { item: LocalFeedItem }) { URL.revokeObjectURL(objectUrl); setUrl(undefined); }; - }, [item]); - const content = attachment?.isText - ? new TextDecoder().decode(attachment.bytes) - : undefined; + }, [attachment]); + const content = useMemo( + () => + attachment?.isText + ? new TextDecoder().decode(attachment.bytes) + : undefined, + [attachment], + ); + const headerLength = item.itemHeader?.length; + const byteLength = + attachment?.bytes.byteLength ?? + (typeof headerLength === "number" && Number.isFinite(headerLength) + ? headerLength + : undefined); return (

{name}

- {url ? ( - - Download attachment - - ) : null} +
+ {url ? ( + + Download attachment + + ) : null} + {collapsible ? ( + // Keep one mounted toggle so keyboard focus survives expanding and collapsing. + + ) : null} +

{attachment?.contentType ?? text(item.itemHeader?.content_type) ?? "Unknown type"} - {attachment - ? ` · ${attachment.bytes.byteLength.toLocaleString()} bytes` - : ""} + {byteLength === undefined + ? "" + : ` · ${byteLength.toLocaleString()} bytes`}

- {!attachment ? ( + {!expanded ? null : !attachment ? (

This attachment could not be decoded. Its original payload is available in JSON. @@ -96,7 +138,7 @@ export function EnvelopeAttachments({ return (

{attachments.map((attachment) => ( - + ))}
); diff --git a/apps/local/src/components/semantic-inspection.test.tsx b/apps/local/src/components/semantic-inspection.test.tsx index 392de3365..7b58c20ec 100644 --- a/apps/local/src/components/semantic-inspection.test.tsx +++ b/apps/local/src/components/semantic-inspection.test.tsx @@ -312,7 +312,7 @@ test.each([ }, ); -test("raw SSE envelopes show attachment previews and keep the original JSON", () => { +test("raw SSE envelopes decode attachment previews on demand and keep the original JSON", () => { const raw = JSON.stringify([ {}, [ @@ -321,19 +321,52 @@ test("raw SSE envelopes show attachment previews and keep the original JSON", () type: "attachment", filename: "notes.txt", content_type: "text/plain", + length: 14, }, { data: "captured notes" }, ], ], ]); + const create = vi.fn((_blob: Blob) => "blob:attachment"); + class TestURL extends URL { + static createObjectURL = create; + static revokeObjectURL = vi.fn(); + } + vi.stubGlobal("URL", TestURL); render( , ); + const attachments = screen.getByRole("region", { + name: "Envelope attachments", + }); + // Collapsed attachments show header metadata without decoding the payload. + expect(attachments.textContent).toContain("notes.txt"); + expect(attachments.textContent).toContain("text/plain · 14 bytes"); + expect(attachments.textContent).not.toContain("captured notes"); + expect(create).not.toHaveBeenCalled(); + + const toggle = screen.getByRole("button", { name: "Show notes.txt preview" }); + fireEvent.click(toggle); + expect(attachments.textContent).toContain("captured notes"); + expect(create).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("link", { name: "Download attachment" }), + ).toBeDefined(); + + // The same button stays mounted, so keyboard focus survives the toggle. + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click( + screen.getByRole("button", { name: "Hide notes.txt preview" }), + ); + expect(screen.getByRole("button", { name: "Show notes.txt preview" })).toBe( + toggle, + ); + expect(attachments.textContent).not.toContain("captured notes"); expect( - screen.getByRole("region", { name: "Envelope attachments" }).textContent, - ).toContain("captured notes"); + screen.queryByRole("link", { name: "Download attachment" }), + ).toBeNull(); fireEvent.click(screen.getByRole("tab", { name: "JSON" })); expect(screen.getByTestId("highlighted-json").textContent).toContain( "notes.txt",