diff --git a/packages/components/src/Gallery/Gallery.stories.tsx b/packages/components/src/Gallery/Gallery.stories.tsx index 009fc8d5a5..82754d697f 100644 --- a/packages/components/src/Gallery/Gallery.stories.tsx +++ b/packages/components/src/Gallery/Gallery.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Gallery } from "@jobber/components/Gallery"; import { GalleryBasicExample } from "./docs/GalleryBasicExample"; import { GalleryMaxFilesExample } from "./docs/GalleryMaxFilesExample"; +import { GalleryWithInputFileExample } from "./docs/GalleryWithInputFileExample"; const meta = { title: "Components/Images and Icons/Gallery", @@ -17,3 +18,7 @@ export const Basic: Story = { export const MaxFiles: Story = { render: GalleryMaxFilesExample, }; + +export const WithInputFile: Story = { + render: GalleryWithInputFileExample, +}; diff --git a/packages/components/src/Gallery/Gallery.test.tsx b/packages/components/src/Gallery/Gallery.test.tsx index 724836252b..78a42ee167 100644 --- a/packages/components/src/Gallery/Gallery.test.tsx +++ b/packages/components/src/Gallery/Gallery.test.tsx @@ -188,6 +188,64 @@ describe("when a non-image is clicked", () => { expect(window.open).toHaveBeenCalledWith(pdfSrc, "_blank"); }); }); + + it("uses getObjectUrl() when available and revokes via requestAnimationFrame", async () => { + const objectUrl = "blob:atlantis-test-pdf"; + const revoke = jest.fn(); + const getObjectUrl = jest.fn(() => ({ url: objectUrl, revoke })); + const rafSpy = jest + .spyOn(window, "requestAnimationFrame") + .mockImplementation(cb => { + cb(0); + + return 0; + }); + + const pdfFile = { + key: "blob-pdf", + name: "blob-sample.pdf", + type: "application/pdf", + size: 1000, + progress: 1, + // src kept for type compatibility; getObjectUrl should be preferred + src: () => Promise.resolve("data:application/pdf;base64,FAKE"), + getObjectUrl, + }; + + const { findByTestId } = render(); + const pdfThumbnail = await findByTestId("pdf"); + + fireEvent.click(pdfThumbnail); + + await waitFor(() => { + expect(getObjectUrl).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenCalledWith(objectUrl, "_blank"); + expect(revoke).toHaveBeenCalledTimes(1); + }); + + rafSpy.mockRestore(); + }); + + it("falls back to src when getObjectUrl is not provided", async () => { + const pdfSrc = "https://example.com/server-stored.pdf"; + const pdfFile = { + key: "string-src-pdf", + name: "server.pdf", + type: "application/pdf", + size: 1000, + progress: 1, + src: pdfSrc, + }; + + const { findByTestId } = render(); + const pdfThumbnail = await findByTestId("pdf"); + + fireEvent.click(pdfThumbnail); + + await waitFor(() => { + expect(window.open).toHaveBeenCalledWith(pdfSrc, "_blank"); + }); + }); }); describe.each([["image/vnd.dwg"], ["image/x-dwg"]])( diff --git a/packages/components/src/Gallery/Gallery.tsx b/packages/components/src/Gallery/Gallery.tsx index f29d826e35..5ef4a39cbe 100644 --- a/packages/components/src/Gallery/Gallery.tsx +++ b/packages/components/src/Gallery/Gallery.tsx @@ -78,11 +78,26 @@ export function Gallery({ files, size = "base", max, onDelete }: GalleryProps) { ); async function handleThumbnailClicked(index: number) { - if (isPreviewableImage(files[index].type)) { + const file = files[index]; + + if (isPreviewableImage(file.type)) { handleLightboxOpen(index); - } else { - window.open(await getFileSrc(files[index]), "_blank"); + + return; } + + if (file.getObjectUrl) { + // Schedule revoke for the next frame so the new tab has time to begin + // loading the resource before the URL is invalidated. + const { url, revoke } = file.getObjectUrl(); + window.open(url, "_blank"); + requestAnimationFrame(revoke); + + return; + } + + // Fallback for GalleryFile objects without getObjectUrl. + window.open(await getFileSrc(file), "_blank"); } function handleLightboxOpen(index: number) { diff --git a/packages/components/src/Gallery/GalleryTypes.ts b/packages/components/src/Gallery/GalleryTypes.ts index 4d5f1c69e0..c37577e72c 100644 --- a/packages/components/src/Gallery/GalleryTypes.ts +++ b/packages/components/src/Gallery/GalleryTypes.ts @@ -34,14 +34,18 @@ export interface GalleryProps { } export interface GalleryFile - extends Pick { + extends Pick< + FileUpload, + "key" | "name" | "type" | "size" | "progress" | "getObjectUrl" + > { /** * The thumbnail url of the file. */ readonly thumbnailSrc?: string; /** - * The data url of the file. + * The data url of the file. For in-memory files, + * prefer using `getObjectUrl`. */ readonly src: string | (() => Promise); } diff --git a/packages/components/src/Gallery/docs/GalleryWithInputFileExample.tsx b/packages/components/src/Gallery/docs/GalleryWithInputFileExample.tsx new file mode 100644 index 0000000000..6033de4384 --- /dev/null +++ b/packages/components/src/Gallery/docs/GalleryWithInputFileExample.tsx @@ -0,0 +1,44 @@ +import React, { useState } from "react"; +import type { FileUpload } from "@jobber/components/InputFile"; +import { InputFile, updateFiles } from "@jobber/components/InputFile"; +import { Gallery } from "@jobber/components/Gallery"; +import { Content } from "@jobber/components/Content"; + +function fetchUploadParams() { + return Promise.resolve({ url: "https://httpbin.org/post" }); +} + +/** + * Image files preview in the LightBox. + * Non-image files (PDF, Word, etc.) open in a new tab via the file's + * `getObjectUrl()` method, which Gallery uses internally to produce a + * navigation-safe URL and revoke it after the new tab has loaded. + */ +export function GalleryWithInputFileExample() { + const [files, setFiles] = useState([]); + + return ( + + + {files.length > 0 && ( + + setFiles(current => current.filter(f => f.key !== file.key)) + } + /> + )} + + ); + + function handleUpload(file: FileUpload) { + setFiles(oldFiles => updateFiles(file, oldFiles)); + } +} diff --git a/packages/components/src/Gallery/docs/index.ts b/packages/components/src/Gallery/docs/index.ts index f0465ac770..54c044ff87 100644 --- a/packages/components/src/Gallery/docs/index.ts +++ b/packages/components/src/Gallery/docs/index.ts @@ -1,2 +1,3 @@ export { GalleryBasicExample } from "./GalleryBasicExample"; export { GalleryMaxFilesExample } from "./GalleryMaxFilesExample"; +export { GalleryWithInputFileExample } from "./GalleryWithInputFileExample"; diff --git a/packages/components/src/InputFile/InputFile.test.tsx b/packages/components/src/InputFile/InputFile.test.tsx index 89d46c4eb1..fd31399b38 100644 --- a/packages/components/src/InputFile/InputFile.test.tsx +++ b/packages/components/src/InputFile/InputFile.test.tsx @@ -124,6 +124,7 @@ describe("Post Requests", () => { size: expect.any(Number), progress: expect.any(Number), src: expect.any(Function), + getObjectUrl: expect.any(Function), type: "image/png", }; @@ -547,3 +548,107 @@ describe("Content", () => { expect(input).toHaveAttribute("name", "file-upload"); }); }); + +describe("FileUpload.getObjectUrl()", () => { + function fetchUploadParams(file: File) { + return Promise.resolve({ + key: file.name, + url: "https://httpbin.org/post", + fields: { secret: "🤫" }, + }); + } + + let createObjectURLMock: jest.Mock; + let revokeObjectURLMock: jest.Mock; + let urlCounter: number; + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + + beforeEach(() => { + urlCounter = 0; + createObjectURLMock = jest.fn(() => `blob:mock-url-${++urlCounter}`); + revokeObjectURLMock = jest.fn(); + // jsdom does not define createObjectURL/revokeObjectURL by default; + // assign directly so we can both stub and inspect them. + URL.createObjectURL = + createObjectURLMock as unknown as typeof URL.createObjectURL; + URL.revokeObjectURL = + revokeObjectURLMock as unknown as typeof URL.revokeObjectURL; + }); + + afterEach(() => { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + }); + + it("returns a blob URL paired with a revoke function", async () => { + const handleStart = jest.fn(); + + render( + , + ); + const input = screen.getByTestId("input-file-input"); + await userEvent.upload(input, testFile); + + await waitFor(() => expect(handleStart).toHaveBeenCalled()); + + const upload = handleStart.mock.calls[0][0]; + const result = upload.getObjectUrl(); + + expect(result.url).toBe("blob:mock-url-1"); + expect(typeof result.revoke).toBe("function"); + expect(createObjectURLMock).toHaveBeenCalledWith(expect.any(File)); + }); + + it("revoke() releases the underlying URL", async () => { + const handleStart = jest.fn(); + + render( + , + ); + const input = screen.getByTestId("input-file-input"); + await userEvent.upload(input, testFile); + + await waitFor(() => expect(handleStart).toHaveBeenCalled()); + + const upload = handleStart.mock.calls[0][0]; + const { url, revoke } = upload.getObjectUrl(); + + expect(revokeObjectURLMock).not.toHaveBeenCalled(); + revoke(); + expect(revokeObjectURLMock).toHaveBeenCalledWith(url); + }); + + // eslint-disable-next-line max-statements + it("produces independent handles on each call", async () => { + const handleStart = jest.fn(); + + render( + , + ); + const input = screen.getByTestId("input-file-input"); + await userEvent.upload(input, testFile); + + await waitFor(() => expect(handleStart).toHaveBeenCalled()); + + const upload = handleStart.mock.calls[0][0]; + const first = upload.getObjectUrl(); + const second = upload.getObjectUrl(); + + expect(first.url).not.toBe(second.url); + expect(createObjectURLMock).toHaveBeenCalledTimes(2); + + first.revoke(); + expect(revokeObjectURLMock).toHaveBeenCalledWith(first.url); + expect(revokeObjectURLMock).not.toHaveBeenCalledWith(second.url); + }); +}); diff --git a/packages/components/src/InputFile/InputFile.tsx b/packages/components/src/InputFile/InputFile.tsx index b508b71389..98ae33cf3e 100644 --- a/packages/components/src/InputFile/InputFile.tsx +++ b/packages/components/src/InputFile/InputFile.tsx @@ -55,10 +55,17 @@ export interface FileUpload { readonly uploadUrl?: string; /** - * The data url of the file. + * The data url of the file. For in-memory files, prefer using `getObjectUrl`. */ src(): Promise; + /** + * Returns a blob object URL for the file, paired with a `revoke` function to + * release it. Call `revoke` once you are done with the URL. Preferred over + * `src()`. + */ + getObjectUrl?(): { url: string; revoke: () => void }; + /** * Callback for when the image file fails to load. * @@ -495,6 +502,7 @@ function getFileUpload( size: file.size, progress: 0, src: getSrc, + getObjectUrl, uploadUrl, }; @@ -518,6 +526,12 @@ function getFileUpload( return promise; } + + function getObjectUrl() { + const url = URL.createObjectURL(file); + + return { url, revoke: () => URL.revokeObjectURL(url) }; + } } /** diff --git a/packages/site/src/content/Gallery/Gallery.stories.mdx b/packages/site/src/content/Gallery/Gallery.stories.mdx index 98b1448b88..b82d2a8af9 100644 --- a/packages/site/src/content/Gallery/Gallery.stories.mdx +++ b/packages/site/src/content/Gallery/Gallery.stories.mdx @@ -30,6 +30,17 @@ beside each other before wrapping subsequent thumbnails to a new line. Each of the thumbnails the Gallery displays are tab navigatable. A hover state will also slightly dim a particular thumbnail when the cursor hovers over it. +## Previewing non-image files + +When a user clicks an image thumbnail, Gallery opens it in an in-page LightBox. +For non-image files (PDF, Word, etc.) Gallery instead opens the file in a new +tab. + +For files produced by ``, Gallery uses the file's `getObjectUrl()` +method to produce a navigation-safe object URL, opens the new tab, then revokes +the URL on the next animation frame. Consumers passing `FileUpload` objects from +`` directly to `` get this behavior automatically. + ## Mockup `, `background-image`, fetch, download +links — and is also valid for top-frame navigation (`window.open`, +``), where data URLs are blocked. This is the recommended +method for new code. + +**Always call `revoke()` when you no longer need the URL**, regardless of how +you used it. Object URLs pin the underlying file in memory until revoked. The +browser auto-revokes on document unload as a safety net, so short-lived flows +don't leak meaningfully — but explicit revocation keeps memory bounded in +long-lived flows (e.g., chat-style attachment composers). + +Pick the revoke timing that matches the usage: + +```tsx +// Navigation: schedule revoke after the new tab has time to begin loading +const { url, revoke } = file.getObjectUrl(); +window.open(url, "_blank"); +requestAnimationFrame(revoke); +``` + +```tsx +// Long-lived state: revoke when the consumer is done with the file +useEffect(() => { + const { url, revoke } = file.getObjectUrl(); + setPreviewUrl(url); + return revoke; +}, [file]); +``` + +Atlantis `` uses `getObjectUrl()` internally on its non-image preview +path — passing `FileUpload` objects directly to `` enables PDF and +other non-image attachment previews without any consumer-side workaround. + +#### `src()` — existing usage + +`src()` resolves to a `data:` URL with the file's bytes embedded inline. It +works for `` rendering and other inline use, but is **blocked** by browsers +from top-frame navigation (`window.open`, ``). + +Existing callers of `src()` continue to work. For new code, prefer +`getObjectUrl()` — it covers the same use cases plus navigation, and we are +moving away from the `src()` pattern over time. + ### validator InputFile can accept a `validator` function as a prop. This will allow you to