fix(ingest): implement beautiful upload loading state and fix button loading spinner visibility #66
Reference in New Issue
Block a user
Delete Branch "bugfix/book-upload-indicator"
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?
This Pull Request fixes the book upload dialog box's lack of visual feedback during the file ingestion process.
Key Changes:
ingesting-stateLoading State: Added a dedicated, beautiful neon-green loader that displaysSaving book to library...with a pulsing neon spinner while the HTTP POST upload request is in flight. This cleanly replaces the form during ingestion, preventing users from clicking disabled/unresponsive inputs or submitting twice..btn-loadingstyling inBookIngestionModal.razor.cssto use a high-contrast white-and-neon-green glowing spinner (border-top-color: var(--nexus-neon)) instead of an invisible black#000spinner on a faded, disabled dark button.dotnet build) and that all unit/integration tests continue to pass perfectly (dotnet test).Code Review — PR #66: Upload Loading State + KnowledgeCoordinator Cancellation + Scroll-to-Top
This PR bundles three independent concerns: (1) the upload loading state UX in
BookIngestionModal, (2) cancellation token support inKnowledgeCoordinator, and (3) thescrollToTopJS function + Blazor bridge inReaderCanvas. All three are well-motivated. The visual upgrade to the button spinner (neon glow replacing the invisible black spinner) and theCancellationTokenSourcelifecycle management are both solid additions.A few issues need resolution before merge, and a handful of smaller items are worth discussing.
🔴 Blocking Issues
1.
ScrollToTopAsyncis called before the DOM is readyIn
LoadChapterAsync,ScrollToTopAsync()is invoked before theawait Mediator.Send(...)call that loads the new chapter content. At this point the old DOM (previous chapter's.reader-canvas) is still in the DOM. If the scroll succeeds it will visually reset the old content, only for the new content to be rendered in an unknown scroll position afterward. The correct location is after the newViewModelhas been set andStateHasChanged()has been called (ideally inside anOnAfterRenderAsyncpass), not before the chapter data arrives.2.
_graphCtsis disposed before the early-return guard inProcessFullPageAsyncIf
fullContentis empty, the method returns leaving_graphCtsholding a freshly allocated (and now orphaned)CancellationTokenSource. The next call toProcessFullPageAsyncwill cancel and dispose it correctly, so there is no permanent leak — butClearAsync()+Dispose()both cancel and dispose_graphCts, meaning aCancellationTokenSourcecancelled byClearAsyncfollowed immediately byDispose()will attempt to cancel an already-cancelled source. More importantly, the early return should either be moved before the CTS setup, or_graphCtsshould be set tonullon the early exit path.3. Double-disposal race in
KnowledgeCoordinator.DisposeDispose()cancels and disposes_graphCts, but does not set it tonull. IfClearAsync()is awaited concurrently (e.g. from a Blazor component teardown racing with a background graph task), bothClearAsyncandDisposewill attempt to cancel and dispose the sameCancellationTokenSource, which throwsObjectDisposedException. Set_graphCts = nulland_quizCts = nullafter disposal inDispose().🟡 Design / Architecture Concerns
4.
ingesting-statedisplay logic is inconsistent withparsing-stateparsing-statecondition:ingesting-statecondition:But
IsIngestingis set totrueat the start ofSaveToLibrary, then set tofalsewhenIsIndexing = trueis set (line 309-310). This meansingesting-stateis already hidden by the timeIsIndexingbecomestrue, so the!IsIndexingguard is always vacuously true during the window wheningesting-stateis shown. The guard is harmless but misleading — either add a comment explaining why it is there, or remove it to align with the intent.5.
btn-loadingclass is applied whileingesting-statereplaces the formWhen
IsIngestingistrue, theverification-statediv is hidden (!IsIngestingin condition), but the "Save to Library" button inside it hasbtn-loadingapplied. Since the button is inside a hiddendisplay:noneelement, this CSS is applied to invisible DOM. The spinner animation runs needlessly, and any CSSfilter(e.g. the newdrop-shadow) will trigger the GPU compositor for an invisible element. Consider only applyingbtn-loadingwhen the element is visible, or restructure so that the spinner lives exclusively iningesting-state.6.
KnowledgeCoordinatorissealedbutIDisposable, notIAsyncDisposableProcessFullPageAsyncandRequestSummaryAndQuizAsyncbothawaitasync operations.ClearAsync()is also async. Yet the class only implementsIDisposable. TheDispose()method calls_graphCts?.Cancel()and_graphCts?.Dispose()synchronously, which is fine forCancellationTokenSource, but it cannot properly await the_graphService.Clear()call thatClearAsync()makes. If a component disposesKnowledgeCoordinator, in-flight graph/quiz state may linger in the UI. Consider implementingIAsyncDisposableand callingawait ClearAsync()insideDisposeAsync().🟢 Minor / Suggestions
7.
scrollToTopuses a class selector that could match multiple elementsdocument.querySelectorreturns the first match. If a future layout ever renders more than one.reader-canvasnode (e.g. during SSR pre-render or animated transitions), this will silently scroll the wrong element. Consider accepting an element reference via aDotNetObjectReferenceor scoping the query to a known container. At minimum, add a comment noting the assumption.8.
ScrollToTopAsyncre-imports the JS module if_viewportModuleis nullThis pattern is shared with
ScrollToNodeAsync. If_viewportModuleis null (beforeOnAfterRenderAsync(firstRender)fires), this creates a second, untrackedIJSObjectReferencethat is never stored or disposed. In MAUI Hybrid this causes a JS module handle leak. Extract the module load into a sharedEnsureViewportModuleAsync()helper that stores the result in_viewportModule, so every call path uses the same cached reference.9.
ProcessFullPageAsyncleaves_graphServicein loading state on early returnIf a previous non-empty call ran
await _graphService.SetLoading(true)and was then cancelled, a subsequent call with emptyfullContentwill return without callingSetLoading(false), leaving the graph panel stuck in the loading state. Move the early-return before theSetLoading(true)call, or callawait _graphService.SetLoading(false)on the early-return path.10. Missing
StateHasChanged()after settingIsIngesting = trueduring@onclickpathIn
SaveToLibrary:This is correct. However,
StateHasChanged()is called synchronously before theawait Http.PostAsJsonAsync(...), which is fine. But be aware that on the MAUI Hybrid host the Blazor dispatcher may batch this with the preceding render tick — theingesting-stateshimmer might not be visible for the first ~16ms frame. This is not a bug, just a nuance worth a comment if users report the spinner feeling "late" on slow devices.@@ -34,2 +34,4 @@</div></div><div class="ingesting-state shimmer" style="@(IsIngesting && !IsIndexing ? "display:flex;" : "display:none;")">🟡
ingesting-stateis hidden by the timeIsIndexingcould be true — redundant guardThe
!IsIndexingcondition in theingesting-statevisibility expression is always vacuously true during the visible window of this state. InSaveToLibrary,IsIngestingis set tofalsebeforeIsIndexingis set totrue(lines 309-310), so the two flags are mutually exclusive by construction.Either:
// !IsIndexing is redundant by construction but kept for defensive consistency with parsing-statestyle="@(IsIngesting ? "display:flex;" : "display:none;")"The current state is not a bug, but it creates a false impression that
IsIngestingandIsIndexingcan both betruesimultaneously.@@ -70,8 +77,8 @@<div class="actions"><NexusButton Class="btn-secondary" OnClick="Reset" Disabled="IsIngesting">Back</NexusButton><NexusButton Class="@($"btn-primary {(IsIngesting ? "btn-loading" : "")}")"🟢 Button spinner runs inside a
display:noneparent — unnecessary GPU compositor workThe
NexusButtonwithbtn-loadingclass is inside.verification-state, which is hidden viadisplay:nonewhenIsIngestingis true. The CSS spinner animation (spin 0.8s linear infinite) and the newfilter: drop-shadow(...)are applied to DOM nodes that are not rendered to the screen.While browsers generally optimize
display:nonesubtrees,filterproperties can still hold GPU resources depending on the browser's paint/compositor implementation. Sinceingesting-statenow provides its own dedicated spinner, consider simplifying the button:The
btn-loadingstate on the button is redundant now thatingesting-stategives full visual feedback.@@ -308,6 +308,11 @@StatusMessage = "Wczytywanie treści...";StateHasChanged();if (string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))🔴
ScrollToTopAsyncis called before chapter content is loaded — wrong position in lifecycleAt this point in
LoadChapterAsync,ViewModelstill holds the previous chapter's content and the DOM hasn't changed yet. CallingscrollToTop()here resets the scroll position of the old chapter, not the new one. When the new chapter eventually renders, the scroll position is uncontrolled.The scroll-to-top should happen after the new
ViewModelis set and the DOM has updated. The correct pattern:Alternatively, perform the scroll inside
OnAfterRenderAsyncafter the chapter load flag clears.@@ -374,6 +379,19 @@}}public async Task ScrollToTopAsync()🔴 Untracked
IJSObjectReferencecreated if_viewportModuleis nullIf
_viewportModulehas not been initialized yet (e.g. this method is called beforeOnAfterRenderAsync(firstRender)runs), a newIJSObjectReferenceis created and returned, but never stored in_viewportModuleand never disposed. In MAUI Hybrid this creates a JS handle leak on every call.Extract this into a shared helper:
Then use
var module = await EnsureViewportModuleAsync();in bothScrollToNodeAsyncandScrollToTopAsync. This same pattern already exists inScrollToNodeAsyncwith the identical risk.@@ -77,6 +80,11 @@ public sealed partial class KnowledgeCoordinator : IDisposablepublic async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null){_graphCts?.Cancel();🔴 CTS is created before the early-return guard — empty
fullContentleaks aCancellationTokenSourceIf
fullContentis empty or whitespace, the method exits here after creating a newCancellationTokenSourceand assigning it to_graphCts. This is not a permanent leak (the next call cancels and disposes it), but it allocates unnecessarily and leaves the coordinator in a state where_graphServicemay still be showing a loading indicator from a previous cancelled call.Move the guard before the CTS setup and the
SetLoading(true)call:@@ -159,6 +200,12 @@ public sealed partial class KnowledgeCoordinator : IDisposablepublic void Dispose()🔴
Dispose()does not null_graphCts/_quizCts— double-disposal race withClearAsyncClearAsynccancels and disposes both CTS fields and sets them tonull.Dispose()cancels and disposes them but does not set them tonull. If a component callsawait coordinator.ClearAsync()and thencoordinator.Dispose()(or vice versa, e.g. during hot-reload or navigation teardown), the second call will attempt to.Cancel()an already-disposedCancellationTokenSource, which throwsObjectDisposedException.Fix:
🟡
KnowledgeCoordinatorshould implementIAsyncDisposable, not justIDisposableThis class owns two async operations (
ProcessFullPageAsync,RequestSummaryAndQuizAsync) and an async cleanup path (ClearAsync). Implementing onlyIDisposablemeansDispose()cannotawait ClearAsync()— it can only cancel the CTS and skip the_graphService.Clear()/_quizService.SetQuiz(null, null)awaitable teardown.If a hosting component (
ReaderCanvas) disposes this coordinator synchronously, the graph panel may remain in a stale loading state.Recommended fix:
Remove the synchronous
Dispose()method and update any DI registrations toAddScoped<KnowledgeCoordinator>()(which supportsIAsyncDisposablein Blazor's DI container).@@ -39,2 +39,4 @@return false;}export function scrollToTop() {🟢
scrollToTopuses a class selector — fragile with multiple.reader-canvasnodesquerySelectorreturns the first matching element. If the DOM ever contains more than one.reader-canvaselement (e.g. during animated SSR-to-WASM transition or if a second reader is opened), this silently scrolls the wrong container.Consider scoping the query or accepting an
ElementReferencefrom the Blazor side:At minimum, add a JSDoc comment:
Code Review Resolutions
Hello! All 10 review items identified in the review have been resolved, build-verified, and fully unit-tested with the latest push:
🔴 Blocking Issues Resolved
ScrollToTopAsyncis called before the DOM is ready:LoadChapterAsync(invoked after setting theViewModeland callingStateHasChanged(), incorporating a50msdelay to allow the browser a frame to render the new chapter content)._graphCtsis disposed before the early-return guard inProcessFullPageAsync:CancelAndDisposeCtshelper, clears the graph state, ensures the loading spinner is stopped, and exits cleanly.Double-disposal race in
KnowledgeCoordinator.Dispose:CancelAndDisposeCtshelper which atomically captures the CTS reference, sets it tonull, cancels the source, and disposes of it under a safetry-catchblock to eliminate theObjectDisposedExceptiondouble-disposal race entirely.🟡 Design / Architecture Concerns Resolved
ingesting-statedisplay logic is inconsistent withparsing-state:IsUploadActivehelper property and refactoring the display style of all five step wrappers insideBookIngestionModal.razorto evaluate simple, mutually exclusive single-boolean states.btn-loadingclass is applied whileingesting-statereplaces the form:IsIngestingis true, theverification-stateis fully hidden and un-rendered, eliminating redundant hidden GPU compositor and layout calculations.KnowledgeCoordinatorissealedbutIDisposable, notIAsyncDisposable:IAsyncDisposable. It now safely awaits the async cleanup of dependency services insideDisposeAsync().🟢 Minor / Suggestions Resolved
scrollToTopuses a class selector that could match multiple elements:scrollToTopfunction insideviewport.jsto accept a custom selector string (defaulting to.reader-canvas), added explanatory assumptions comments, and scoped the invocation appropriately.ScrollToTopAsyncre-imports the JS module if_viewportModuleis null:EnsureViewportModuleAsync()helper method inReaderCanvas.razorto guarantee singular import loading and completely avoid untracked reference leaks.ProcessFullPageAsyncleaves_graphServicein loading state on early return:await _graphService.SetLoading(false), ensuring loading indicators do not hang indefinitely.Missing
StateHasChanged()after settingIsIngesting = trueduring@onclickpath:BookIngestionModal.razordescribing the dispatcher batching behavior and ensuring future developers are aware of MAUI Hybrid rendering thread nuances.🧪 Verification
dotnet build NexusReader.slnx --no-restore).20/20 tests passed).