Split TranscriptionEditView.svelte (332 lines) — extract auto-save + drag-drop modules
#199
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Context
TranscriptionEditView.svelteis 332 lines mixing three independent concerns: debounced auto-save state machine, pointer-based drag-and-drop reordering, and block list rendering. The auto-save and drag logic are pure imperative code with zero template coupling.Proposed Split
1.
useBlockAutoSave.svelte.ts(~100 lines)Extract the debounced save state machine:
saveStates,debounceTimers,pendingTextsgetSaveState,setSaveState,executeSave,scheduleSavedFade,scheduleDebounce,clearDebounce,flushAllPending,flushViaBeaconbeforeunloadlistener setup{ getSaveState, handleTextChange, handleRetry, handleDelete, handleBlur }2.
useBlockDragDrop.svelte.ts(~60 lines)Extract pointer-based drag-and-drop:
draggedBlockId,dropTargetIdx,dragOffsetYhandleGripDown,handlePointerMove,handlePointerUp{ draggedBlockId, dropTargetIdx, dragOffsetY, handleGripDown, handlePointerMove, handlePointerUp }3.
TranscriptionEditView.sveltebecomes orchestrator (~80 lines)Acceptance Criteria
sendBeaconflush on page unload still worksnpm run checkpasses👨💻 Felix Brandt -- Senior Fullstack Developer
Good decomposition -- extracting imperative logic into
.svelte.tsmodules is exactly the right pattern for Svelte 5. A few things I'd want clarified before implementation:Naming and API surface
use*prefix suggests these return reactive state. Confirm: willuseBlockAutoSaveanduseBlockDragDropbe factory functions that return an object of callbacks + reactive state, or will they export standalone functions?handleTextChange,handleRetry,handleDelete,handleBluras exposed from the auto-save hook.handleDeletefeels like it belongs to block management, not auto-save. Is its only purpose clearing the save timer for a deleted block, or does it also handle the DELETE API call? If the latter, it crosses concerns.Reactive collections
SvelteMap-basedsaveStates,debounceTimers,pendingTexts. Since these live inside a.svelte.tsmodule, confirm they'll be initialized with$state()wrapping or asnew SvelteMap()instances so Svelte's reactivity tracks them. A plainMapinside a module file would silently break reactivity in the consuming component.Testing strategy
scheduleSavedFadetransitionssaved -> idleafter timeoutscheduleDebouncecoalesces rapid text changes into one saveflushAllPendingfires immediately for all dirty blocksflushViaBeaconusesnavigator.sendBeacon, notfetchdropTargetIdx) should be extractable and testable in isolation.Component size target
TranscriptionBlockalready exists as a separate component. If the block rendering is currently inline inTranscriptionEditView, this refactor implicitly creates a third extraction (TranscriptionBlock.svelte) not mentioned in the issue. Worth confirming scope.Suggestion
executeSaveshould accept the API call as an injected dependency (callback parameter) rather than importing the fetch logic directly. This makes the module testable without mocking network calls and aligns with dependency inversion.🏗️ Markus Keller -- Senior Application Architect
Clean module boundary identification. The three concerns (auto-save state machine, drag-drop interaction, block list rendering) are genuinely independent -- this is not premature extraction. A few architectural considerations:
Module boundary contracts
useBlockAutoSaveneeds to know: the block ID, the current text, and an API call to persist. How does it get these? Options:saveFn: (blockId: string, text: string) => Promise<void>-- cleanest, fully decoupledState ownership
draggedBlockId,dropTargetIdx, anddragOffsetYinsideuseBlockDragDrop. But who owns the canonical block order? If the orchestrator ownsblocks: Block[]and the drag module just signals "move block X to position Y", the boundary is clean. If the drag module mutates the block array directly, you've split ownership and created a coordination problem.useBlockDragDropshould expose aonReorder: (fromId: string, toIdx: number) => voidcallback that the orchestrator implements. The drag module manages visual state (ghost position, drop indicator); the orchestrator manages data state (block order, API call to persist new order).beforeunloadandsendBeaconplacementbeforeunloadlistener inuseBlockAutoSave. This is correct -- the auto-save module is the only one that knows about pending unsaved text. But confirm: does the listener also need to abort in-flight drag operations? If a user closes the tab mid-drag, is there any state to clean up? Probably not, but worth a one-line check.Accidental complexity check
No over-extraction
🧪 Sara Holt -- Senior QA Engineer
This refactor is a testability win -- extracting the auto-save state machine and drag-drop logic into standalone modules makes them independently testable for the first time. Here's what I'd want covered:
Auto-save module test plan
The state machine (idle -> saving -> saved -> fading -> idle, with error branching) is the highest-value test target:
handleTextChangecalls within the debounce window should result in exactly oneexecuteSavecallexecuteSavefailure sets state toerror;handleRetryre-triggers save with the last pending textflushAllPending: fires saves for all blocks with pending text, regardless of debounce timersflushViaBeacon: usesnavigator.sendBeacon(notfetch), and gracefully handlessendBeaconreturningfalse(payload too large)setTimeoutreferences)All of these are testable with
vi.useFakeTimers()in Vitest -- no DOM or browser needed.Drag-drop module test plan
handleGripDown->handlePointerMove->handlePointerUpproduces a reorder callback with correctfromIdandtoIdxhandlePointerUpfires without a precedinghandleGripDown(e.g., focus lost), no crashRegression coverage
Flakiness risk
vi.useFakeTimers()andvi.advanceTimersByTime()exclusively -- neversetTimeoutwith real timers in tests.PointerEventconstruction, ensure the test environment supports it (jsdom has limitations here). Consider testing the reorder logic separately from the pointer event handling.🔒 Nora "NullX" Steiner -- Application Security Engineer
This is a refactor, not new functionality, so the attack surface should remain unchanged. That said, a few things to verify during implementation:
sendBeaconpayload integrityflushViaBeaconsends unsaved text to the backend on page unload. Confirm:sendBeaconsends cookies by default, but if the app uses a customAuthorizationheader, that header is NOT sent with beacon requests. This is a common gap that results in either (a) unauthenticated writes or (b) silent data loss.sendBeaconwith aBlobdefaults totext/plain, which may bypass Spring's@RequestBodyJSON parsing.Drag-and-drop reorder authorization
@RequirePermission(Permission.WRITE_ALL)or equivalent.Timer cleanup on navigation
beforeunload. Confirm that the auto-save module cleans up debounce and fade timers in anonDestroyor equivalent lifecycle hook, not just inbeforeunload. Otherwise, navigating away from the transcription page (without closing the tab) could leak timers or fire saves after the component is unmounted.No new XSS surface
{@html}usage), the XSS posture is unchanged. Worth a quick grep after implementation to confirm no{@html}was introduced.🎨 Leonie Voss -- UI/UX Design Lead
This is a code-level refactor, so the user-facing behavior should be pixel-identical. My concerns are about preserving the existing UX during extraction:
Auto-save feedback states
getSaveStatereactive value updates synchronously with the UI -- no extra render frame delay introduced by the module boundary.Drag-and-drop visual feedback
dropTargetIdx. Confirm:dragOffsetY) remains smooth during the drag. If the pointer move handler is now in a separate module, verify there's no additional reactivity overhead causing jank.Accessibility preservation
Suggestion
aria-busy="true"during saving,aria-labelupdate on error). Worth checking if this is already in place.⚙️ Tobias Wendt -- DevOps & Platform Engineer
Pure frontend refactor -- no infrastructure changes needed. A few things to verify from the build and CI perspective:
Build impact
.svelte.tsfiles should be tree-shaken correctly by Vite. No bundle size increase expected, but worth checking the build output diff before and after:TranscriptionEditViewsplits into multiple chunks, that's fine as long as they're loaded together (same route).CI pipeline
npm run check(svelte-check) is listed in acceptance criteria -- good, that's already in the CI pipeline.npm run lintshould also pass. New.svelte.tsfiles need to follow the same ESLint + Prettier config. Confirm the lint glob pattern covers**/*.svelte.tsfiles (some older configs only match*.tsand*.svelteseparately).Hot module replacement (HMR)
.svelte.tsmodules with reactive state ($state,SvelteMap) can behave unexpectedly during HMR in dev mode. If a developer editsuseBlockAutoSave.svelte.ts, Vite may re-execute the module but the consuming component keeps its old references. This can cause "ghost timers" or duplicatebeforeunloadlisteners during development.No new dependencies
SvelteMap,$state,$derived). No additions topackage.jsonexpected.Source map quality
useBlockAutoSave.svelte.ts:42is more debuggable than a chunk hash. Vite's default source map config should handle this, but worth a quick check in the browser devtools after build.Response to all persona reviews
I read all six comments against the actual source code. Here are concrete answers organized by persona.
Felix Brandt (Senior Fullstack Developer)
Factory function vs standalone exports: Factory function is the right call. Both modules need initialization parameters (
useBlockAutoSaveneedsdocumentIdand asaveFn;useBlockDragDropneeds the sorted blocks list and areorderFn). They should return an object of callbacks + reactive state. Not standalone exports.handleDeletecrossing concerns: Confirmed it crosses concerns.TranscriptionEditView.svelte:130-135showshandleDeletedoes three things: (1) clears debounce timer, (2) clears pending text and save state, (3) callsonDeleteBlock. Items 1-2 are auto-save cleanup; item 3 is block management owned by the parent. After extraction,handleDeletein the orchestrator should callautoSave.clearBlock(blockId)(cleanup method on the auto-save module) then callonDeleteBlock(blockId). The auto-save module should NOT own the delete API call.SvelteMapreactivity: Confirmed. Lines 39-41 already usenew SvelteMap()(imported fromsvelte/reactivity), not plainMap. This will work correctly inside.svelte.tsmodules -- Svelte 5's reactivity system tracksSvelteMapreads/writes across module boundaries.executeSavedependency injection: Agreed. CurrentlyexecuteSave(line 53) delegates toonSaveBlockwhich is already a prop callback. After extraction, the factory function should acceptsaveFn: (blockId: string, text: string) => Promise<void>as a parameter. This is already the shape ofonSaveBlockin the current Props (line 17). Clean 1:1 mapping.TranscriptionBlockscope:TranscriptionBlock.sveltealready exists as a separate component (272 lines). No implicit third extraction needed. The orchestrator just passes hook-derived props to it.Markus Keller (Senior Application Architect)
Module input contracts:
useBlockAutoSave(options: { documentId: string, saveFn: (blockId: string, text: string) => Promise<void> })-- returns{ getSaveState, handleTextChange, handleRetry, clearBlock, handleBlur, flushViaBeacon, destroy }useBlockDragDrop(options: { getSortedBlocks: () => Block[], onReorder: (newOrder: string[]) => void })-- returns{ draggedBlockId, dropTargetIdx, dragOffsetY, handleGripDown, handlePointerMove, handlePointerUp }Option 1 (injected
saveFn) is what we'll use. The current code already uses this pattern --onSaveBlockprop at line 17.State ownership for block order: Confirmed the orchestrator should own block order. Currently
reorder()at line 137-154 mutatesblocksdirectly AND makes the API call. After extraction,useBlockDragDropshould only manage visual state (draggedBlockId,dropTargetIdx,dragOffsetY) and callonReorder(newOrder: string[])on drop. The orchestrator implementsonReorderto call the API and update the block array. The drag module never touches the data array.beforeunload+ drag cleanup: No drag state needs cleanup on unload. The drag state (draggedBlockId,dropTargetIdx,dragOffsetY) is purely visual -- no server-side effect, no pending data. Closing the tab mid-drag is harmless.Line count increase: Agreed this is acceptable. The goal is cognitive load per file, not total line count.
Sara Holt (Senior QA Engineer)
Existing tests: Zero. No test files exist matching
*Transcription*test*or*transcription*test*anywhere infrontend/src/. This is greenfield.Test plan for auto-save module: All six scenarios listed are testable with
vi.useFakeTimers(). The factory function pattern makes this straightforward -- inject a mocksaveFn, callhandleTextChange, advance timers, assertsaveFncall count andgetSaveStatetransitions. Specific timings from the code:TranscriptionEditView.svelte:87)Debounce coalescing test approach:
Drag-drop reorder logic: The index calculation at lines 216-220 (
splice/insertAtadjustment) should be extracted into a pure function and tested separately. Pointer event simulation is unnecessary for unit tests.E2E tests: Agree these should be created as part of this issue, not deferred. The acceptance criteria require behavioral parity -- the only reliable way to verify that is an E2E test.
Flakiness:
vi.useFakeTimers()exclusively. No real timers in tests.Nora "NullX" Steiner (Application Security Engineer)
sendBeaconauth -- THIS IS A REAL BUG (pre-existing, not introduced by this refactor):The app uses Basic Auth via
Authorizationheader, not cookie-based sessions (SecurityConfig.java:46explicitly disables CSRF with a comment explaining this).sendBeaconatTranscriptionEditView.svelte:232-238callsnavigator.sendBeacon(url, blob)directly from the browser.sendBeaconcannot set custom headers -- it only sends cookies.vite.config.ts:23-29) extractsauth_tokenfrom cookies and injects theAuthorizationheader, so it works.sendBeacon('/api/...')hits the SvelteKit Node server, butsendBeaconis NOT a SvelteKitfetch-- it bypasseshandleFetchentirely. There is no catch-all/apiSvelteKit route for transcription block updates. The beacon request will get a 404 or hit the SvelteKit fallback.Result:
flushViaBeaconsilently loses data in production. This should be filed as a separate bug. The fix options:sendBeaconwithfetch(..., { keepalive: true })routed through a SvelteKit server endpoint that injects authsrc/routes/api/[...path]/+server.tsContent-Type: The current code correctly sets
type: 'application/json'on the Blob (line 236), so Spring's@RequestBodyparsing would work IF the request reaches the backend.CSRF on reorder endpoint: CSRF is globally disabled (
SecurityConfig.java:46). The reorder endpoint atTranscriptionBlockController.java:75-76is protected by@RequirePermission(Permission.WRITE_ALL). No CSRF concern.IDOR on reorder:
TranscriptionService.reorderBlocks()(line 110-117) callsgetBlock(documentId, blockIds.get(i))for each block ID, which usesfindByIdAndDocumentId(blockId, documentId)(line 43). Block IDs from a different document will throwTRANSCRIPTION_BLOCK_NOT_FOUND. IDOR is properly prevented.Timer cleanup on SvelteKit navigation: The
$effectcleanup at lines 241-254 runs when the component is destroyed (SvelteKit navigation), clearing thebeforeunloadlistener and all debounce timers. However,scheduleSavedFadeat lines 69-80 createssetTimeoutcalls that are NOT tracked or cleaned up on destroy. These are fire-and-forget timers that could execute after navigation, callingsetSaveStateon a destroyedSvelteMap. This is a minor leak -- it won't crash (the map still exists in memory until GC), but it's sloppy. The extracted module should track fade timers and clear them indestroy().No
{@html}in transcription components: Confirmed. Grep for{@html}inTranscriptionEditView.svelteandTranscriptionBlock.sveltereturns zero matches. No XSS surface change.Leonie Voss (UI/UX Design Lead)
Save indicator timing: The timings are hardcoded constants: 1500ms debounce, 2000ms "saved" display, 300ms fade-out (
TranscriptionEditView.svelte:70-78,87). After extraction these become constants inuseBlockAutoSave.svelte.ts. No reactivity overhead is added by the module boundary --getSaveStatereturns from aSvelteMap.get()which is synchronous. No extra render frame delay.Drop indicator positioning: The drop indicator renders between blocks via
{#if dropTargetIdx === i}at line 267-269, and after the last block at line 300-302. This logic stays in the orchestrator template, not in the drag module. The module only exposes reactivedropTargetIdxstate. No risk of rendering on top of blocks.Drag grip touch targets: The drag grip at
TranscriptionBlock.svelte:137-143is adivwith no explicit width/height set -- it relies on the text content (the braille character). This is likely under 44x44px. However, on mobile the grip ishidden(md:blockat line 138) and replaced by arrow buttons (md:hiddenat lines 127, 146) which areh-7 w-7(28x28px) -- also under the 44px minimum. This is a pre-existing a11y issue, not introduced by this refactor. Worth a follow-up issue.Keyboard reorder: Currently supported via the mobile arrow buttons (
onMoveUp/onMoveDownatTranscriptionBlock.svelte:130-131,149-150), which are<button>elements and therefore keyboard-focusable. These are always present in the DOM (just hidden on desktop viamd:hidden). Keyboard users on desktop cannot access them. A follow-up issue for keyboard reorder on desktop (e.g., Shift+ArrowUp/Down on focused block) would be appropriate.ARIA live regions: None exist. No
aria-live,role="status", orrole="alert"on the save state indicator (TranscriptionBlock.svelte:209-231). The extractedgetSaveStatereactive value makes it easy to addaria-live="polite"on the status container. Should be a follow-up issue, not scope-creep on this refactor.Tobias Wendt (DevOps & Platform Engineer)
Bundle impact: The two new
.svelte.tsfiles will be in the same chunk asTranscriptionEditView.sveltesince they're direct imports on the same route. Vite won't split them into separate chunks. No lazy-loading boundary crossed. Net bundle size change: negligible (import boilerplate only).Lint coverage for
.svelte.ts: Confirmed covered.eslint.config.js:35includes**/*.svelte.tsin its file glob. No config change needed.HMR ghost timer risk: Real concern. When Vite HMR re-executes
useBlockAutoSave.svelte.ts, the old module instance's timers survive but the cleanup function from the old$effectmay not fire if the consuming component wasn't re-created. Mitigation: the factory function pattern helps here -- if the component re-renders and calls the factory again, the old instance'sdestroy()is called by the component's$effectcleanup. Adding a brief code comment noting "full page reload recommended after editing this module in dev" is reasonable.No new dependencies: Confirmed. Only
svelte/reactivity(SvelteMap) and standard Svelte 5 runes. Nopackage.jsonchanges.Source maps: Vite preserves original file names in source maps by default with
adapter-node.useBlockAutoSave.svelte.ts:42will appear correctly in stack traces.Summary of action items for implementation
handleDeletestays in orchestrator; auto-save module exposesclearBlock(blockId)for cleanupdestroy()(fixes minor timer leak)sendBeaconsilent data loss in production (pre-existing, blocks this refactor's acceptance criteria since "sendBeacon flush on page unload still works" cannot pass in prod)vi.useFakeTimers()-- include in this issue