diff --git a/services/web/frontend/js/features/file-view/components/file-view-image.tsx b/services/web/frontend/js/features/file-view/components/file-view-image.tsx
index f15640e00e..5e43502503 100644
--- a/services/web/frontend/js/features/file-view/components/file-view-image.tsx
+++ b/services/web/frontend/js/features/file-view/components/file-view-image.tsx
@@ -1,5 +1,8 @@
+import { useState, useEffect } from 'react'
import { useProjectContext } from '../../../shared/context/project-context'
+import { debugConsole } from '@/utils/debugging'
import { BinaryFile } from '@/features/file-view/types/binary-file'
+import useAbortController from '@/shared/hooks/use-abort-controller'
export default function FileViewImage({
file,
@@ -11,12 +14,70 @@ export default function FileViewImage({
onError: () => void
}) {
const { projectId } = useProjectContext()
- return (
-
- )
+
+ const urlPath = `/project/${projectId}/blob/${file.hash}`
+ const extension = file.name.split('.')?.pop()?.toLowerCase()
+
+ if (extension === 'svg') {
+ return (
+
+ )
+ } else {
+ return (
+
+ )
+ }
+}
+
+type SVGRendererProps = {
+ url: string
+ alt: string
+ onLoad: () => void
+ onError: () => void
+}
+
+function SVGRenderer({ url, alt, onLoad, onError }: SVGRendererProps) {
+ const { signal } = useAbortController()
+ const [objectUrl, setObjectUrl] = useState(null)
+
+ useEffect(() => {
+ let blobUrl: string | null = null
+ setObjectUrl(null)
+ fetch(url, { signal })
+ .then(res => {
+ if (!res.ok) {
+ throw new Error(`Error fetching SVG: ${res.statusText}`)
+ }
+ return res.arrayBuffer()
+ })
+ .then(buffer => {
+ const blob = new Blob([buffer], { type: 'image/svg+xml' })
+ blobUrl = URL.createObjectURL(blob)
+ setObjectUrl(blobUrl)
+ })
+ .catch(err => {
+ if (signal.aborted) return
+ debugConsole.error('Unable to render SVG', err)
+ onError()
+ })
+
+ return () => {
+ if (blobUrl) {
+ // URL.createObjectURL() allocates memory that is not garbage-collected automatically,
+ // we're explicitly releasing it on effect cleanup.
+ URL.revokeObjectURL(blobUrl)
+ }
+ }
+ }, [url, onError, signal])
+
+ if (!objectUrl) {
+ return null
+ }
+
+ return
}
diff --git a/services/web/frontend/js/features/file-view/components/file-view.tsx b/services/web/frontend/js/features/file-view/components/file-view.tsx
index 172efd773c..0a40178a87 100644
--- a/services/web/frontend/js/features/file-view/components/file-view.tsx
+++ b/services/web/frontend/js/features/file-view/components/file-view.tsx
@@ -9,7 +9,7 @@ import LoadingSpinner from '@/shared/components/loading-spinner'
import getMeta from '@/utils/meta'
import { BinaryFile } from '../types/binary-file'
-const imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
+const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg']
export default function FileView({ file }: { file: BinaryFile }) {
const [contentLoading, setContentLoading] = useState(true)
diff --git a/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx b/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx
index 7a2486a0b2..90ecd081ae 100644
--- a/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx
+++ b/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx
@@ -1,14 +1,70 @@
-import { screen } from '@testing-library/react'
+import { expect } from 'chai'
+import { screen, waitFor } from '@testing-library/react'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import FileViewImage from '../../../../../frontend/js/features/file-view/components/file-view-image'
import { imageFile } from '../util/files'
+import type { BinaryFile } from '@/features/file-view/types/binary-file'
+
+const svgFile: BinaryFile<'project_file'> = {
+ ...imageFile,
+ name: 'diagram.svg',
+}
+
+const svgContent =
+ ''
describe('', function () {
- it('renders an image', function () {
+ beforeEach(function () {
+ URL.createObjectURL = sinon.stub().returns('blob:fake-url')
+ URL.revokeObjectURL = sinon.stub()
+ })
+
+ afterEach(function () {
+ fetchMock.removeRoutes().clearHistory()
+ })
+
+ it('renders a non-SVG img', function () {
renderWithEditorContext(
{}} onLoad={() => {}} />
)
screen.getByRole('img')
})
+
+ it('fetches and renders SVG in an img tag via blob URL', async function () {
+ fetchMock.get('express:/project/:project_id/blob/:hash', svgContent)
+
+ renderWithEditorContext(
+ {}} onLoad={() => {}} />
+ )
+
+ await waitFor(() => {
+ const img = screen.getByRole('img')
+ expect(img.getAttribute('src')).to.equal('blob:fake-url')
+ })
+ const createObjectURLStub = URL.createObjectURL as sinon.SinonStub
+ expect(createObjectURLStub.calledOnce).to.be.true
+ const blob = createObjectURLStub.firstCall.args[0]
+ expect(blob).to.be.instanceOf(Blob)
+ expect(blob.type).to.equal('image/svg+xml')
+ })
+
+ it('calls onError when SVG fetch fails', async function () {
+ fetchMock.get('express:/project/:project_id/blob/:hash', {
+ throws: new Error('Network error'),
+ })
+ const onError = sinon.stub()
+
+ renderWithEditorContext(
+ {}} onError={onError} />
+ )
+
+ await waitFor(() => {
+ sinon.assert.calledOnce(onError)
+ })
+
+ expect(screen.queryByRole('img')).to.not.exist
+ })
})