refactor(fileloader): extract createFileLoader hook from document/enrich pages

Move blob URL lifecycle management into a reusable createFileLoader()
hook that owns revoke-before-create and revoke-on-destroy. Replace
identical inline logic in documents/[id] and enrich/[id] with the hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 13:20:32 +02:00
parent dbf7f0bc16
commit 34e7436fdc
4 changed files with 154 additions and 62 deletions

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createFileLoader } from '../useFileLoader.svelte';
const FAKE_URL = 'blob:fake-url';
function setupFetch(ok: boolean, body?: Blob) {
const blob = body ?? new Blob(['fake'], { type: 'application/pdf' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
blob: vi.fn().mockResolvedValue(blob)
})
);
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('createFileLoader', () => {
it('sets fileUrl after a successful fetch', async () => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL: vi.fn()
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
expect(loader.fileUrl).toBe(FAKE_URL);
expect(loader.isLoading).toBe(false);
expect(loader.fileError).toBe('');
});
it('sets fileError on a failed fetch (non-ok response)', async () => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn(),
revokeObjectURL: vi.fn()
});
setupFetch(false);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
expect(loader.fileUrl).toBe('');
expect(loader.fileError).not.toBe('');
expect(loader.isLoading).toBe(false);
});
it('revokes the previous URL before creating a new one', async () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
// First load: no previous URL to revoke
expect(revokeObjectURL).not.toHaveBeenCalled();
await loader.loadFile('/api/documents/2/file');
// Second load: previous URL should be revoked
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
});
it('revokes the URL on destroy', async () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
loader.destroy();
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
});
it('does not revoke when no URL has been set', () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn(),
revokeObjectURL
});
const loader = createFileLoader();
loader.destroy();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
export function createFileLoader() {
let fileUrl = $state('');
let isLoading = $state(false);
let fileError = $state('');
async function loadFile(url: string): Promise<void> {
isLoading = true;
fileError = '';
if (fileUrl) URL.revokeObjectURL(fileUrl);
fileUrl = '';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load file');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
function destroy(): void {
if (fileUrl) URL.revokeObjectURL(fileUrl);
}
return {
get fileUrl() {
return fileUrl;
},
get isLoading() {
return isLoading;
},
get fileError() {
return fileError;
},
loadFile,
destroy
};
}

View File

@@ -9,6 +9,7 @@ import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.s
import type { TranscriptionBlockData } from '$lib/types'; import type { TranscriptionBlockData } from '$lib/types';
import { getErrorMessage } from '$lib/errors'; import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
let { data } = $props(); let { data } = $props();
@@ -18,43 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ────────────────────────────────────────────────────────────── // ── File loading ──────────────────────────────────────────────────────────────
let fileUrl = $state(''); const fileLoader = createFileLoader();
let isLoading = $state(false);
let fileError = $state('');
$effect(() => { $effect(() => {
if (doc?.id && doc?.filePath) { if (doc?.id && doc?.filePath) {
loadFile(doc.id); fileLoader.loadFile(`/api/documents/${doc.id}/file`);
} }
}); });
async function loadFile(id: string) { onDestroy(() => fileLoader.destroy());
isLoading = true;
fileError = '';
if (fileUrl) URL.revokeObjectURL(fileUrl);
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) {
if (response.status === 401) throw new Error('Nicht eingeloggt');
throw new Error('Fehler beim Laden der Datei');
}
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
onDestroy(() => {
if (fileUrl) URL.revokeObjectURL(fileUrl);
});
// ── Mode state ─────────────────────────────────────────────────────────────── // ── Mode state ───────────────────────────────────────────────────────────────
@@ -350,7 +323,7 @@ onMount(() => {
<DocumentTopBar <DocumentTopBar
doc={doc} doc={doc}
canWrite={canWrite} canWrite={canWrite}
fileUrl={fileUrl} fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode} bind:transcribeMode={transcribeMode}
/> />
@@ -362,9 +335,9 @@ onMount(() => {
> >
<DocumentViewer <DocumentViewer
doc={doc} doc={doc}
fileUrl={fileUrl} fileUrl={fileLoader.fileUrl}
isLoading={isLoading} isLoading={fileLoader.isLoading}
error={fileError} error={fileLoader.fileError}
transcribeMode={transcribeMode && !ocrRunning} transcribeMode={transcribeMode && !ocrRunning}
blockNumbers={blockNumbers} blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey} annotationReloadKey={annotationReloadKey}

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { onMount, onDestroy, untrack } from 'svelte'; import { onMount, onDestroy, untrack } from 'svelte';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte'; import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
@@ -11,9 +12,7 @@ let { data, form } = $props();
const doc = $derived(data.document); const doc = $derived(data.document);
// File preview state // File preview state
let fileUrl = $state(''); const fileLoader = createFileLoader();
let fileError = $state('');
let isLoading = $state(false);
let navHeight = $state(0); let navHeight = $state(0);
onMount(() => { onMount(() => {
@@ -27,30 +26,11 @@ let activeAnnotationPage = $state<number | null>(null);
$effect(() => { $effect(() => {
if (doc?.id && doc?.filePath) { if (doc?.id && doc?.filePath) {
loadFile(doc.id); fileLoader.loadFile(`/api/documents/${doc.id}/file`);
} }
}); });
async function loadFile(id: string) { onDestroy(() => fileLoader.destroy());
isLoading = true;
fileError = '';
if (fileUrl) URL.revokeObjectURL(fileUrl);
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) throw new Error('Fehler');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
onDestroy(() => {
if (fileUrl) URL.revokeObjectURL(fileUrl);
});
// Form state // Form state
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? [])); let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
@@ -93,9 +73,9 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
<div class="relative flex-[6] overflow-hidden border-r border-line"> <div class="relative flex-[6] overflow-hidden border-r border-line">
<DocumentViewer <DocumentViewer
doc={doc} doc={doc}
fileUrl={fileUrl} fileUrl={fileLoader.fileUrl}
isLoading={isLoading} isLoading={fileLoader.isLoading}
error={fileError} error={fileLoader.fileError}
bind:annotateMode={annotateMode} bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage} bind:activeAnnotationPage={activeAnnotationPage}