@* Top Bar *@
@@ -71,9 +71,11 @@
protected override void OnInitialized()
{
- StateService.OnStateChanged += StateHasChanged;
+ StateService.OnStateChanged += OnStateChanged;
}
+ private void OnStateChanged() => InvokeAsync(StateHasChanged);
+
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -117,9 +119,24 @@
InvokeAsync(StateHasChanged);
}
- public void Dispose()
+ public async ValueTask DisposeAsync()
{
- StateService.OnStateChanged -= StateHasChanged;
- _jsRef?.Dispose();
+ StateService.OnStateChanged -= OnStateChanged;
+
+ // Clear the JS-side reference before disposing it, otherwise the global
+ // keyboard handler keeps invoking a disposed DotNetObjectReference.
+ if (_jsRef is not null)
+ {
+ try
+ {
+ await JS.InvokeVoidAsync("cloudDrive.keyboard.unregisterUndoRedo");
+ }
+ catch (JSDisconnectedException)
+ {
+ // Circuit already gone; nothing to unregister.
+ }
+
+ _jsRef.Dispose();
+ }
}
}
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Layout/TopBar.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Layout/TopBar.razor
index ca14b23..fd0a27f 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Layout/TopBar.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Layout/TopBar.razor
@@ -124,11 +124,14 @@
protected override async Task OnInitializedAsync()
{
- UndoRedoService.OnHistoryChanged += StateHasChanged;
- GitHubAuthService.OnCredentialsChanged += StateHasChanged;
+ UndoRedoService.OnHistoryChanged += RequestRender;
+ GitHubAuthService.OnCredentialsChanged += RequestRender;
await GitHubAuthService.InitializeAsync();
}
+ // Service events may fire on non-UI threads; marshal to the Dispatcher.
+ private void RequestRender() => InvokeAsync(StateHasChanged);
+
private void HandleSearchKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(SearchQuery))
@@ -139,7 +142,7 @@
public void Dispose()
{
- UndoRedoService.OnHistoryChanged -= StateHasChanged;
- GitHubAuthService.OnCredentialsChanged -= StateHasChanged;
+ UndoRedoService.OnHistoryChanged -= RequestRender;
+ GitHubAuthService.OnCredentialsChanged -= RequestRender;
}
}
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Home.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Home.razor
index 81fc870..9ac8c88 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Home.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Home.razor
@@ -188,12 +188,15 @@
protected override void OnInitialized()
{
- StateService.OnDataChanged += StateHasChanged;
- StateService.OnCurrentPathChanged += StateHasChanged;
- StateService.OnSortChanged += StateHasChanged;
- StateService.OnViewModeChanged += StateHasChanged;
+ StateService.OnDataChanged += RequestRender;
+ StateService.OnCurrentPathChanged += RequestRender;
+ StateService.OnSortChanged += RequestRender;
+ StateService.OnViewModeChanged += RequestRender;
}
+ // Service events may fire on non-UI threads; marshal to the Dispatcher.
+ private void RequestRender() => InvokeAsync(StateHasChanged);
+
private IEnumerable
GetCurrentItems()
{
var currentPath = StateService.CurrentPath;
@@ -479,10 +482,10 @@
public void Dispose()
{
- StateService.OnDataChanged -= StateHasChanged;
- StateService.OnCurrentPathChanged -= StateHasChanged;
- StateService.OnSortChanged -= StateHasChanged;
- StateService.OnViewModeChanged -= StateHasChanged;
+ StateService.OnDataChanged -= RequestRender;
+ StateService.OnCurrentPathChanged -= RequestRender;
+ StateService.OnSortChanged -= RequestRender;
+ StateService.OnViewModeChanged -= RequestRender;
}
private class FileItem
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Recent.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Recent.razor
index fcc1af7..9df33b0 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Recent.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Recent.razor
@@ -74,9 +74,12 @@
@code {
protected override void OnInitialized()
{
- RecentFiles.OnChange += StateHasChanged;
+ RecentFiles.OnChange += RequestRender;
}
+ // Service events may fire on non-UI threads; marshal to the Dispatcher.
+ private void RequestRender() => InvokeAsync(StateHasChanged);
+
private void OpenItem(RecentFileEntry entry)
{
var exists = entry.IsDirectory ? VFS.DirectoryExists(entry.Path) : VFS.FileExists(entry.Path);
@@ -117,6 +120,6 @@
public void Dispose()
{
- RecentFiles.OnChange -= StateHasChanged;
+ RecentFiles.OnChange -= RequestRender;
}
}
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/RecycleBin.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/RecycleBin.razor
index 29e0d79..cbaea50 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/RecycleBin.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/RecycleBin.razor
@@ -77,9 +77,12 @@
@code {
protected override void OnInitialized()
{
- RecycleBinService.OnChange += StateHasChanged;
+ RecycleBinService.OnChange += RequestRender;
}
+ // Service events may fire on non-UI threads; marshal to the Dispatcher.
+ private void RequestRender() => InvokeAsync(StateHasChanged);
+
private void RestoreItem(Guid itemId)
{
if (RecycleBinService.Restore(VFS, itemId))
@@ -118,6 +121,6 @@
public void Dispose()
{
- RecycleBinService.OnChange -= StateHasChanged;
+ RecycleBinService.OnChange -= RequestRender;
}
}
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Starred.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Starred.razor
index b55bc23..40c94d1 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Starred.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Starred.razor
@@ -79,10 +79,14 @@
protected override void OnInitialized()
{
- StateService.OnItemStarred += _ => StateHasChanged();
- StateService.OnDataChanged += StateHasChanged;
+ StateService.OnItemStarred += OnItemStarred;
+ StateService.OnDataChanged += OnDataChanged;
}
+ private void OnItemStarred(string _) => InvokeAsync(StateHasChanged);
+
+ private void OnDataChanged() => InvokeAsync(StateHasChanged);
+
private void OpenItem(StarredItem item)
{
if (!item.Exists) return;
@@ -106,7 +110,8 @@
public void Dispose()
{
- StateService.OnDataChanged -= StateHasChanged;
+ StateService.OnItemStarred -= OnItemStarred;
+ StateService.OnDataChanged -= OnDataChanged;
}
private class StarredItem
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/UndoRedoButtons.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/UndoRedoButtons.razor
index b584e37..0c22c09 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/UndoRedoButtons.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/UndoRedoButtons.razor
@@ -26,9 +26,12 @@
@code {
protected override void OnInitialized()
{
- UndoRedoService.OnHistoryChanged += StateHasChanged;
+ UndoRedoService.OnHistoryChanged += RequestRender;
}
+ // OnHistoryChanged can fire from background continuations; marshal to the Dispatcher.
+ private void RequestRender() => InvokeAsync(StateHasChanged);
+
private void HandleUndo()
{
UndoRedoService.Undo();
@@ -41,6 +44,6 @@
public void Dispose()
{
- UndoRedoService.OnHistoryChanged -= StateHasChanged;
+ UndoRedoService.OnHistoryChanged -= RequestRender;
}
}
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubMetadataTracker.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubMetadataTracker.cs
index bbc01e7..d4c6bf8 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubMetadataTracker.cs
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubMetadataTracker.cs
@@ -23,6 +23,11 @@ public sealed class GitHubMetadataTracker : IDisposable
public GitHubMetadataTracker(IVirtualFileSystem vfs)
{
_vfs = vfs;
+
+ // Auto-sync metadata with VFS structural changes. The tracker is scoped
+ // per circuit and unsubscribes on Dispose, so subscribing here keeps the
+ // mapping correct for the lifetime of the circuit.
+ SubscribeToVfsEvents();
}
///
@@ -180,8 +185,10 @@ private void OnDirectoryDeleted(VFSEventArgs args)
if (args is VFSDirectoryDeletedArgs dirArgs)
{
var prefix = NormalizePath(dirArgs.Path.Value);
+ var childPrefix = prefix + "/";
var keysToRemove = _metadataByPath.Keys
- .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Where(k => k.Equals(prefix, StringComparison.OrdinalIgnoreCase)
+ || k.StartsWith(childPrefix, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var key in keysToRemove)
@@ -202,9 +209,11 @@ private void OnDirectoryMoved(VFSEventArgs args)
{
var oldPrefix = NormalizePath(moveArgs.SourcePath.Value);
var newPrefix = NormalizePath(moveArgs.DestinationPath.Value);
+ var oldChildPrefix = oldPrefix + "/";
var entriesToUpdate = _metadataByPath
- .Where(kvp => kvp.Key.StartsWith(oldPrefix, StringComparison.OrdinalIgnoreCase))
+ .Where(kvp => kvp.Key.Equals(oldPrefix, StringComparison.OrdinalIgnoreCase)
+ || kvp.Key.StartsWith(oldChildPrefix, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var (oldPath, metadata) in entriesToUpdate)
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/wwwroot/js/interop.js b/src/Atypical.VirtualFileSystem.DemoBlazorApp/wwwroot/js/interop.js
index 2ab2923..edad6db 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/wwwroot/js/interop.js
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/wwwroot/js/interop.js
@@ -117,6 +117,10 @@ window.cloudDrive = {
this.undoRedoRef = dotNetRef;
},
+ unregisterUndoRedo: function () {
+ this.undoRedoRef = null;
+ },
+
handleGridNavigation: function (containerId, dotNetRef) {
const container = document.getElementById(containerId);
if (!container) return;
From bb2c71f4162a57834d664058093986ac676b45ad Mon Sep 17 00:00:00 2001
From: Philippe Matray
Date: Tue, 2 Jun 2026 10:45:43 +0200
Subject: [PATCH 07/11] fix(blazor): thread-safe ToastService and encrypted PAT
storage
- ToastService backed its state with a plain List mutated from a background
Task.Run (auto-removal) with no synchronization, while Toasts exposed a live
AsReadOnly view that components enumerate during render -> potential
"Collection was modified" races. Auto-removal also never raised OnChange, so
ToastContainer relied on a 500ms polling timer. Now all access is locked, the
Toasts getter returns a snapshot, auto-removal goes through Remove() which
raises OnChange, and the redundant polling timer is removed.
- GitHubAuthService stored the raw GitHub PAT in plaintext localStorage. It now
uses ProtectedLocalStorage (encrypted with the server Data Protection key).
Also fixed UpdateRateLimits overwriting a known reset time with null.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Components/UI/ToastContainer.razor | 11 +----
.../Services/GitHubAuthService.cs | 28 ++++++-----
.../Services/ToastService.cs | 48 +++++++++++--------
3 files changed, 47 insertions(+), 40 deletions(-)
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/ToastContainer.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/ToastContainer.razor
index e68d971..eaf1d0d 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/ToastContainer.razor
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/UI/ToastContainer.razor
@@ -10,19 +10,13 @@
@code {
- private System.Threading.Timer? _timer;
-
protected override void OnInitialized()
{
ToastService.OnChange += HandleChange;
-
- // Periodic check to handle auto-removal (every 500ms)
- _timer = new System.Threading.Timer(_ =>
- {
- InvokeAsync(StateHasChanged);
- }, null, 500, 500);
}
+ // OnChange now fires on auto-removal too (from a background task), so marshal
+ // to the Dispatcher; no polling timer is needed.
private void HandleChange()
{
InvokeAsync(StateHasChanged);
@@ -36,6 +30,5 @@
public void Dispose()
{
ToastService.OnChange -= HandleChange;
- _timer?.Dispose();
}
}
diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubAuthService.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubAuthService.cs
index a00203c..8a3f6a1 100644
--- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubAuthService.cs
+++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/GitHubAuthService.cs
@@ -1,19 +1,21 @@
using Atypical.VirtualFileSystem.DemoBlazorApp.Models;
-using Microsoft.JSInterop;
+using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Octokit;
namespace Atypical.VirtualFileSystem.DemoBlazorApp.Services;
///