fix(fileloader): prevent infinite reload loop via untrack #243
Reference in New Issue
Block a user
Delete Branch "fix/file-loader-reactive-loop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
loadFile()readsfileUrlsynchronously before its firstawait, which causes any calling$effectto accidentally subscribe tofileUrlas a reactive dependencyfileUrl = blobUrl, fires the signal, re-runs the$effect, which callsloadFileagain — infinite loopfileUrlread inuntrack()so callers never subscribe to itRoot cause detail
In Svelte 5,
$effecttracks all$statereads that occur synchronously anywhere in the call stack while the effect runs — including inside called functions. TheloadFilehook readsfileUrlin its very first sync statement (if (fileUrl) URL.revokeObjectURL(fileUrl)), before the firstawait. This silently registersfileUrlas a dependency of whichever$effectinvokesloadFile.The loop:
$effectfires → callsloadFile(url)→ sync body readsfileUrl→ effect now tracks itfileUrl = blobUrl→ signal fires$effectre-runs → callsloadFileagain → revokesblobUrl, setsfileUrl = ''fileUrlchanged →$effectre-runs → goto 2This manifested as the PDF viewer cycling endlessly through loading → mounted → loading, visible as "page keeps refreshing".
Test plan
useFileLoaderunit tests pass (npx vitest run useFileLoader)🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Summary
The fix is correct and minimal. The root cause —
$effectaccidentally trackingfileUrlbecauseloadFile's synchronous preamble reads it before the firstawait— is a classic Svelte 5 pitfall, anduntrack()is exactly the right tool here.What I checked
Reactive tracking boundary: In Svelte 5,
$effecttracks all$statereads that happen synchronously in the entire call stack, including inside functions called from the effect. The old code didif (fileUrl) URL.revokeObjectURL(fileUrl)synchronously, which registeredfileUrlas a dependency of the calling$effect. After a successful load,fileUrlwas set to a new blob URL, the effect re-ran, revoked it, setfileUrl = '', triggering another re-run — infinite loop.The fix:
const prev = untrack(() => fileUrl)readsfileUrloutside the reactive tracking context. The calling effect never learns about thefileUrlsubscription, so it doesn't re-run whenfileUrlchanges.Existing tests: All 5 unit tests in
useFileLoader.svelte.test.tspass. They cover revoke-on-reload, revoke-on-destroy, error state, and happy path.Comment quality: The new inline comment clearly explains the why, not just the what. This is the right call for a non-obvious Svelte 5 subtlety.
One observation (non-blocking)
The unit tests run
loadFilein isolation (plainasynccalls), so they can't detect the reactive loop regression. That loop only manifests inside a Svelte$effecttracking context. A component-level test with@testing-library/sveltethat exercises the$effect→loadFile→ state-change cycle would be the only way to guard against this class of bug regressing. Not a blocker for this fix — just worth noting for the team's testing strategy.🏗️ Markus Keller — Software Architect
Verdict: ✅ Approved
Summary
Single-file, single-responsibility fix. Nothing to object to architecturally — in fact the change reinforces the encapsulation contract that
createFileLoaderis supposed to provide.What I checked
Hook encapsulation boundary:
createFileLoaderis designed to hide file loading state from callers. The bug was a leaky abstraction: the internalfileUrlstate was bleeding out of the hook's reactive boundary and infecting the caller's$effect. The fix seals that leak. The hook's public contract (load a file, exposefileUrl/isLoading/fileError) remains unchanged — callers readfileUrlvia the getter and that read is correctly tracked. Only the internal read insideloadFileis now shielded.No layer boundary violations: The hook stays in
$lib/hooks/, dependencies unchanged, no new cross-module coupling introduced.untrackas a design tool: Usinguntrackfor internal bookkeeping reads is a well-established Svelte 5 pattern (the Svelte docs explicitly recommend it for "previous value" tracking inside$effect). This is boring, correct usage — exactly what I want to see.Risk of the change: Near-zero. The
prevvariable is populated with the currentfileUrlvalue before any mutation. The revoke-and-reset logic is identical to before, just protected from reactive tracking. No behavioural change under any load path.🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
Summary
The fix itself is correct and the existing unit tests still pass. My concern is that the test suite cannot actually prevent this class of bug from regressing — and we're approving without closing that gap.
What I checked
Existing test coverage (
useFileLoader.svelte.test.ts):fileUrlis set after a successful fetchfileErroris set on failurerevokeObjectURLis called indestroy()revokeObjectURLis not called iffileUrlis emptyThese are all unit-level tests that call
loadFiledirectly in anasynccontext — not inside a Svelte$effect. The infinite loop only manifests when the hook is used inside a reactive tracking context. The existing tests would have passed even with the broken code.Concern (non-blocking for merge, but flagged)
There is no component-level or integration-level test that exercises the
$effect → loadFile → fileUrl change → $effect re-runs?cycle. If someone refactorsloadFileand accidentally removes theuntrackcall (or introduces a new synchronous$stateread before the firstawait), the tests won't catch it.A minimal regression guard could be a
@testing-library/sveltecomponent test that mounts a component usingcreateFileLoaderinside an$effect, callsloadFile, and asserts it was called exactly once — not in a loop. This is harder to write than a unit test but would be the only real safety net for this bug class.I'm not blocking merge since the fix is correct and the inline comment documents the intent. But I'd like to see a follow-up issue opened to track adding that component-level test.
🔒 Nora "NullX" Steiner — Security Engineer
Verdict: ✅ Approved
Summary
No security surface changes. LGTM.
What I checked
Blob URL lifecycle: The revoke-before-replace pattern is preserved.
URL.revokeObjectURL(prev)is called on the previous URL before creating a new one, thenfileUrl = ''before the async fetch begins. This means there is no window where an old blob URL remains accessible after the new one is loaded. Memory leak risk is unchanged (unchanged for the better — the blob lifecycle management is correct).Network requests:
fetch(url)is unchanged. Theurlparameter is still passed in from the caller (in+page.svelte, it's a fixed API path/api/documents/${doc.id}/file). No user-controlled URL concatenation at the hook level.No new dependencies:
untrackis a Svelte built-in export from thesveltepackage. No third-party packages introduced, no supply-chain risk.No new state exposure: The hook's public API (three read-only getters +
loadFile+destroy) is unchanged. No internal state is newly exposed.🎨 Leonie Voss — UI/UX Expert
Verdict: ✅ Approved
Summary
This is a pure bug fix with no UI changes — but the UX impact is significant. The infinite reload loop was causing the document page to flash and reload continuously, which is one of the worst possible user experiences. This fix restores a stable, usable document viewer.
What I checked
UX regression fixed: The infinite loop manifested as the document page continuously refreshing — the PDF viewer would flash, the loading spinner would reappear, and any user interaction (scrolling, annotating) would be interrupted. This was a showstopper for usability on the document detail page. The fix resolves it completely.
No visual regressions: The change is confined to
useFileLoader.svelte.tsand touches only internal reactive tracking logic. The rendered output — the PDF viewer, loading states, error states — is identical before and after.Loading state UX is preserved:
isLoadingis still set totrueat the start ofloadFileandfalseinfinally, so the loading spinner inDocumentViewerstill shows correctly during the fetch. Users get the expected feedback while the file loads.No concerns from a UI/UX perspective. Ship it.
🚀 Tobias Wendt — DevOps Engineer
Verdict: ✅ Approved
Summary
No infrastructure, build, or deployment concerns. This change is frontend-only and has no operational surface area.
What I checked
No new dependencies:
untrackis re-exported from thesveltepackage which is already a direct dependency. Nopackage.jsonchanges, no lockfile changes, no new supply-chain exposure.Build pipeline: The change is a single
.tsfile in the frontend. The existingnpm run build,npm run check, andnpm run testpipeline steps are unaffected. The fix was already validated locally withnpx vitest run useFileLoader.No environment or config changes: No
.envvariables, no Docker Compose changes, no Flyway migrations, no Spring config changes.Deployment risk: Near-zero. This is a frontend-only fix that resolves a client-side infinite loop. No backend restart required, no database changes, no migration window needed. Standard deploy applies.