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