fix(reader): resolve PR #66 code review issues

- Shifted ScrollToTopAsync timing to post-render inside LoadChapterAsync.
- Refactored viewport module loading using EnsureViewportModuleAsync to prevent leaks.
- Added selector parameter to scrollToTop in viewport.js.
- Implemented IAsyncDisposable and thread-safe CTS management in KnowledgeCoordinator.
- Fixed early-return loading status and memory leaks in KnowledgeCoordinator.
- Simplified BookIngestionModal state rendering with mutual exclusivity.
- Documented thread-affinity dispatching (InvokeAsync) in BookIngestionModal.
This commit is contained in:
2026-06-01 18:41:00 +02:00
parent bd9e9bd036
commit f066253701
4 changed files with 99 additions and 45 deletions
@@ -27,21 +27,21 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="parsing-state shimmer" style="@(IsParsing && !IsIndexing ? "display:flex;" : "display:none;")"> <div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
<div class="shimmer-content"> <div class="shimmer-content">
<div class="spinner"></div> <div class="spinner"></div>
<p>Scanning metadata...</p> <p>Scanning metadata...</p>
</div> </div>
</div> </div>
<div class="ingesting-state shimmer" style="@(IsIngesting && !IsIndexing ? "display:flex;" : "display:none;")"> <div class="ingesting-state shimmer" style="@(IsIngesting ? "display:flex;" : "display:none;")">
<div class="shimmer-content"> <div class="shimmer-content">
<div class="spinner"></div> <div class="spinner"></div>
<p>Saving book to library...</p> <p>Saving book to library...</p>
</div> </div>
</div> </div>
<div class="verification-state" style="@(IsVerifying && !IsParsing && !IsIndexing && !IsIngesting ? "display:flex;" : "display:none;")"> <div class="verification-state" style="@(IsVerifying ? "display:flex;" : "display:none;")">
@if (Metadata != null) @if (Metadata != null)
{ {
<div class="verification-layout"> <div class="verification-layout">
@@ -86,9 +86,9 @@
</div> </div>
<div class="upload-state @(_isDragging ? "drag-over" : "")" <div class="upload-state @(_isDragging ? "drag-over" : "")"
style="@(!IsParsing && !IsVerifying && !IsIndexing && !IsIngesting ? "display:flex;" : "display:none;")" style="@(IsUploadActive ? "display:flex;" : "display:none;")"
@ondragenter="OnDragEnter" @ondragenter="OnDragEnter"
@ondragleave="OnDragLeave"> @ondragleave="OnDragLeave">
<div class="drop-zone"> <div class="drop-zone">
<InputFile id="epub-upload" OnChange="HandleFileSelected" accept=".epub" class="file-input-cover" /> <InputFile id="epub-upload" OnChange="HandleFileSelected" accept=".epub" class="file-input-cover" />
<div class="drop-zone-content"> <div class="drop-zone-content">
@@ -150,6 +150,7 @@
private string? ErrorMessage { get; set; } private string? ErrorMessage { get; set; }
private byte[]? _epubBytes; private byte[]? _epubBytes;
private bool _disposed; private bool _disposed;
private bool IsUploadActive => !IsParsing && !IsVerifying && !IsIngesting && !IsIndexing;
// Allow up to 50 MB // Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024; private const long MaxFileSize = 50 * 1024 * 1024;
@@ -170,6 +171,8 @@
if (!_disposed) if (!_disposed)
{ {
// Dispatch the state change to the Blazor synchronization context
// because this event is triggered asynchronously from a SignalR / WebSocket background thread.
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -184,6 +187,8 @@
if (IngestedBookId != Guid.Empty) if (IngestedBookId != Guid.Empty)
{ {
var bookId = IngestedBookId; var bookId = IngestedBookId;
// Dispatch UI updates and navigation back to the Blazor thread
// to avoid thread affinity issues and potential UI lockups in MAUI/Web applications.
await InvokeAsync(async () => { await InvokeAsync(async () => {
if (_disposed) return; if (_disposed) return;
await CloseModal(); await CloseModal();
@@ -159,15 +159,24 @@
} }
} }
private async ValueTask<IJSObjectReference> EnsureViewportModuleAsync()
{
if (_viewportModule == null)
{
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
}
return _viewportModule;
}
private async Task InitViewportDetectionAsync() private async Task InitViewportDetectionAsync()
{ {
try try
{ {
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); var module = await EnsureViewportModuleAsync();
_selfReference = DotNetObjectReference.Create(this); _selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport"); var isMobileViewport = await module.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport); await OnViewportChanged(isMobileViewport);
await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference); await module.InvokeVoidAsync("registerViewportObserver", _selfReference);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -308,11 +317,6 @@
StatusMessage = "Wczytywanie treści..."; StatusMessage = "Wczytywanie treści...";
StateHasChanged(); StateHasChanged();
if (string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
{
await ScrollToTopAsync();
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
@@ -353,16 +357,25 @@
_isLoadingChapter = false; _isLoadingChapter = false;
StateHasChanged(); StateHasChanged();
if (result.IsSuccess && !string.IsNullOrEmpty(NavigationService.PendingScrollBlockId)) if (result.IsSuccess)
{ {
var targetBlockId = NavigationService.PendingScrollBlockId; if (!string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls {
_currentActiveBlockId = targetBlockId; var targetBlockId = NavigationService.PendingScrollBlockId;
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
_currentActiveBlockId = targetBlockId;
// Give the browser slightly more than one frame to render the loaded blocks // Give the browser slightly more than one frame to render the loaded blocks
await Task.Delay(150); await Task.Delay(150);
await ScrollToNodeAsync(targetBlockId); await ScrollToNodeAsync(targetBlockId);
await InteractionService.RequestHighlightBlock(targetBlockId); await InteractionService.RequestHighlightBlock(targetBlockId);
}
else
{
// Reset scroll to top now that the new content DOM is rendered
await Task.Delay(50); // Give the browser a frame to render the new chapter content
await ScrollToTopAsync();
}
} }
} }
@@ -370,7 +383,7 @@
{ {
try try
{ {
var module = _viewportModule ?? await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollIntoView", id); await module.InvokeVoidAsync("scrollIntoView", id);
} }
catch (Exception ex) catch (Exception ex)
@@ -383,8 +396,8 @@
{ {
try try
{ {
var module = _viewportModule ?? await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollToTop"); await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public sealed partial class KnowledgeCoordinator : IDisposable public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
{ {
private readonly IKnowledgeService _knowledgeService; private readonly IKnowledgeService _knowledgeService;
private readonly IKnowledgeGraphService _graphService; private readonly IKnowledgeGraphService _graphService;
@@ -78,15 +78,39 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
} }
private void CancelAndDisposeCts(ref CancellationTokenSource? cts)
{
var localCts = cts;
cts = null;
if (localCts != null)
{
try
{
localCts.Cancel();
}
catch (ObjectDisposedException) { }
finally
{
localCts.Dispose();
}
}
}
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null) public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null)
{ {
_graphCts?.Cancel(); if (string.IsNullOrWhiteSpace(fullContent))
_graphCts?.Dispose(); {
CancelAndDisposeCts(ref _graphCts);
await _graphService.Clear();
await _graphService.SetLoading(false);
CurrentFullPageContent = string.Empty;
return;
}
CancelAndDisposeCts(ref _graphCts);
_graphCts = new CancellationTokenSource(); _graphCts = new CancellationTokenSource();
var token = _graphCts.Token; var token = _graphCts.Token;
if (string.IsNullOrWhiteSpace(fullContent)) return;
CurrentFullPageContent = fullContent; CurrentFullPageContent = fullContent;
LogGeneratingGraph(tenantId); LogGeneratingGraph(tenantId);
@@ -135,8 +159,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global") public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{ {
_quizCts?.Cancel(); CancelAndDisposeCts(ref _quizCts);
_quizCts?.Dispose();
_quizCts = new CancellationTokenSource(); _quizCts = new CancellationTokenSource();
var token = _quizCts.Token; var token = _quizCts.Token;
@@ -184,13 +207,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync() public async Task ClearAsync()
{ {
_graphCts?.Cancel(); CancelAndDisposeCts(ref _graphCts);
_graphCts?.Dispose(); CancelAndDisposeCts(ref _quizCts);
_graphCts = null;
_quizCts?.Cancel();
_quizCts?.Dispose();
_quizCts = null;
CurrentFullPageContent = string.Empty; CurrentFullPageContent = string.Empty;
await _graphService.Clear(); await _graphService.Clear();
@@ -201,11 +219,26 @@ public sealed partial class KnowledgeCoordinator : IDisposable
{ {
_interactionService.OnNodeSelected -= HandleNodeSelected; _interactionService.OnNodeSelected -= HandleNodeSelected;
_graphCts?.Cancel(); CancelAndDisposeCts(ref _graphCts);
_graphCts?.Dispose(); CancelAndDisposeCts(ref _quizCts);
}
_quizCts?.Cancel(); public async ValueTask DisposeAsync()
_quizCts?.Dispose(); {
_interactionService.OnNodeSelected -= HandleNodeSelected;
CancelAndDisposeCts(ref _graphCts);
CancelAndDisposeCts(ref _quizCts);
try
{
await _graphService.Clear();
await _quizService.SetQuiz(null, null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error clearing services during KnowledgeCoordinator disposal.");
}
} }
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")] [LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
@@ -39,8 +39,10 @@ export function scrollIntoView(id) {
return false; return false;
} }
export function scrollToTop() { // NOTE: Assumes the selector matches the active scroll container (default '.reader-canvas').
const el = document.querySelector('.reader-canvas'); // Scoping is flexible to avoid issues if SSR pre-render or animated layouts render multiple wrappers.
export function scrollToTop(selector = '.reader-canvas') {
const el = document.querySelector(selector);
if (el) { if (el) {
el.scrollTop = 0; el.scrollTop = 0;
return true; return true;
@@ -48,3 +50,4 @@ export function scrollToTop() {
return false; return false;
} }