fix(ingest): implement beautiful upload loading state and fix button loading spinner visibility #66
@@ -308,6 +308,11 @@
|
|||||||
StatusMessage = "Wczytywanie treści...";
|
StatusMessage = "Wczytywanie treści...";
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
|
||||||
|
mjasin marked this conversation as resolved
Outdated
|
|||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -374,6 +379,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ScrollToTopAsync()
|
||||||
|
mjasin marked this conversation as resolved
Outdated
Antigravity
commented
🔴 Untracked If Extract this into a shared helper: Then use 🔴 **Untracked `IJSObjectReference` created if `_viewportModule` is null**
```csharp
var module = _viewportModule ?? await JS.InvokeAsync<IJSObjectReference>("import", "...");
```
If `_viewportModule` has not been initialized yet (e.g. this method is called before `OnAfterRenderAsync(firstRender)` runs), a *new* `IJSObjectReference` is created and returned, but **never stored in `_viewportModule` and never disposed**. In MAUI Hybrid this creates a JS handle leak on every call.
Extract this into a shared helper:
```csharp
private async Task<IJSObjectReference> EnsureViewportModuleAsync()
{
_viewportModule ??= await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
return _viewportModule;
}
```
Then use `var module = await EnsureViewportModuleAsync();` in both `ScrollToNodeAsync` and `ScrollToTopAsync`. This same pattern already exists in `ScrollToNodeAsync` with the identical risk.
|
|||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var module = _viewportModule ?? await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
|
||||||
|
await module.InvokeVoidAsync("scrollToTop");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to scroll reader canvas to top.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void HandleEscape()
|
private void HandleEscape()
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
private readonly IPlatformService _platformService;
|
private readonly IPlatformService _platformService;
|
||||||
private readonly IReaderInteractionService _interactionService;
|
private readonly IReaderInteractionService _interactionService;
|
||||||
private readonly ILogger<KnowledgeCoordinator> _logger;
|
private readonly ILogger<KnowledgeCoordinator> _logger;
|
||||||
|
|
||||||
|
private CancellationTokenSource? _graphCts;
|
||||||
|
private CancellationTokenSource? _quizCts;
|
||||||
|
|
||||||
public string CurrentFullPageContent { get; private set; } = string.Empty;
|
public string CurrentFullPageContent { get; private set; } = string.Empty;
|
||||||
|
|
||||||
@@ -77,6 +80,11 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
|
|
||||||
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();
|
||||||
|
mjasin marked this conversation as resolved
Outdated
Antigravity
commented
🔴 CTS is created before the early-return guard — empty If Move the guard before the CTS setup and the 🔴 **CTS is created before the early-return guard — empty `fullContent` leaks a `CancellationTokenSource`**
If `fullContent` is empty or whitespace, the method exits here after creating a new `CancellationTokenSource` and 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 `_graphService` may still be showing a loading indicator from a previous cancelled call.
Move the guard **before** the CTS setup and the `SetLoading(true)` call:
```csharp
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null)
{
if (string.IsNullOrWhiteSpace(fullContent)) return; // ← move here
_graphCts?.Cancel();
_graphCts?.Dispose();
_graphCts = new CancellationTokenSource();
var token = _graphCts.Token;
// ... rest of method
}
```
|
|||||||
|
_graphCts?.Dispose();
|
||||||
|
_graphCts = new CancellationTokenSource();
|
||||||
|
var token = _graphCts.Token;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(fullContent)) return;
|
if (string.IsNullOrWhiteSpace(fullContent)) return;
|
||||||
|
|
||||||
CurrentFullPageContent = fullContent;
|
CurrentFullPageContent = fullContent;
|
||||||
@@ -87,7 +95,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId);
|
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId, token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
var packet = result.Value;
|
var packet = result.Value;
|
||||||
@@ -103,10 +113,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
|
|
||||||
await _graphService.SetLoading(false);
|
await _graphService.SetLoading(false);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[KnowledgeCoordinator] Graph generation task was canceled.");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await _graphService.SetLoading(false);
|
if (!token.IsCancellationRequested)
|
||||||
LogGraphError(ex, tenantId);
|
{
|
||||||
|
await _graphService.SetLoading(false);
|
||||||
|
LogGraphError(ex, tenantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +135,18 @@ 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();
|
||||||
|
_quizCts?.Dispose();
|
||||||
|
_quizCts = new CancellationTokenSource();
|
||||||
|
var token = _quizCts.Token;
|
||||||
|
|
||||||
await _quizService.SetHydrating(true);
|
await _quizService.SetHydrating(true);
|
||||||
LogRequestingSummary(tenantId);
|
LogRequestingSummary(tenantId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
|
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId, cancellationToken: token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
var packet = result.Value;
|
var packet = result.Value;
|
||||||
@@ -138,10 +162,19 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
LogSummaryWarning(tenantId);
|
LogSummaryWarning(tenantId);
|
||||||
return Result.Fail(result.Errors);
|
return Result.Fail(result.Errors);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[KnowledgeCoordinator] Quiz and summary generation task was canceled.");
|
||||||
|
return Result.Fail("Task canceled");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
LogSummaryError(ex, tenantId);
|
if (!token.IsCancellationRequested)
|
||||||
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
{
|
||||||
|
LogSummaryError(ex, tenantId);
|
||||||
|
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
||||||
|
}
|
||||||
|
return Result.Fail("Task canceled");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -151,6 +184,14 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
|
|
||||||
public async Task ClearAsync()
|
public async Task ClearAsync()
|
||||||
{
|
{
|
||||||
|
_graphCts?.Cancel();
|
||||||
|
_graphCts?.Dispose();
|
||||||
|
_graphCts = null;
|
||||||
|
|
||||||
|
_quizCts?.Cancel();
|
||||||
|
_quizCts?.Dispose();
|
||||||
|
_quizCts = null;
|
||||||
|
|
||||||
CurrentFullPageContent = string.Empty;
|
CurrentFullPageContent = string.Empty;
|
||||||
await _graphService.Clear();
|
await _graphService.Clear();
|
||||||
await _quizService.SetQuiz(null, null);
|
await _quizService.SetQuiz(null, null);
|
||||||
@@ -159,6 +200,12 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
mjasin marked this conversation as resolved
Outdated
Antigravity
commented
🔴
Fix: 🔴 **`Dispose()` does not null `_graphCts`/`_quizCts` — double-disposal race with `ClearAsync`**
`ClearAsync` cancels and disposes both CTS fields and sets them to `null`. `Dispose()` cancels and disposes them but does **not** set them to `null`. If a component calls `await coordinator.ClearAsync()` and then `coordinator.Dispose()` (or vice versa, e.g. during hot-reload or navigation teardown), the second call will attempt to `.Cancel()` an already-disposed `CancellationTokenSource`, which throws `ObjectDisposedException`.
Fix:
```csharp
public void Dispose()
{
_interactionService.OnNodeSelected -= HandleNodeSelected;
_graphCts?.Cancel();
_graphCts?.Dispose();
_graphCts = null; // ← add this
_quizCts?.Cancel();
_quizCts?.Dispose();
_quizCts = null; // ← add this
}
```
Antigravity
commented
🟡 This class owns two async operations ( If a hosting component ( Recommended fix: Remove the synchronous 🟡 **`KnowledgeCoordinator` should implement `IAsyncDisposable`, not just `IDisposable`**
This class owns two async operations (`ProcessFullPageAsync`, `RequestSummaryAndQuizAsync`) and an async cleanup path (`ClearAsync`). Implementing only `IDisposable` means `Dispose()` cannot `await 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:
```csharp
public sealed partial class KnowledgeCoordinator : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
_interactionService.OnNodeSelected -= HandleNodeSelected;
await ClearAsync();
}
}
```
Remove the synchronous `Dispose()` method and update any DI registrations to `AddScoped<KnowledgeCoordinator>()` (which supports `IAsyncDisposable` in Blazor's DI container).
|
|||||||
{
|
{
|
||||||
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
||||||
|
|
||||||
|
_graphCts?.Cancel();
|
||||||
|
_graphCts?.Dispose();
|
||||||
|
|
||||||
|
_quizCts?.Cancel();
|
||||||
|
_quizCts?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
[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}")]
|
||||||
|
|||||||
@@ -38,3 +38,13 @@ export function scrollIntoView(id) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scrollToTop() {
|
||||||
|
mjasin marked this conversation as resolved
Outdated
Antigravity
commented
🟢
Consider scoping the query or accepting an At minimum, add a JSDoc comment: 🟢 **`scrollToTop` uses a class selector — fragile with multiple `.reader-canvas` nodes**
```js
const el = document.querySelector('.reader-canvas');
```
`querySelector` returns the *first* matching element. If the DOM ever contains more than one `.reader-canvas` element (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 `ElementReference` from the Blazor side:
```js
export function scrollToTop(elementRef) {
const el = elementRef ?? document.querySelector('.reader-canvas');
if (el) {
el.scrollTop = 0;
return true;
}
return false;
}
```
At minimum, add a JSDoc comment:
```js
/** Assumes a single .reader-canvas element exists in the DOM. */
export function scrollToTop() {
```
|
|||||||
|
const el = document.querySelector('.reader-canvas');
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
🔴
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.