From 2407cae60956d903e915cf8a2ea658286fd81a21 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 11 Jul 2026 22:38:20 +0800 Subject: [PATCH 01/42] docs: define navigation rebuild design --- .../2026-07-11-navigation-rebuild-design.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md diff --git a/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md new file mode 100644 index 0000000..441bcb6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md @@ -0,0 +1,328 @@ +# SimpleNavigation Rebuild Design + +## Status + +This design was approved section by section on 2026-07-11. All implementation work is based on the `rebuild` branch. + +## Summary + +The rebuild separates region ownership from navigation services, replaces the public `RegionService` attached-property owner with `Region`, adds navigation for non-page WPF content, and adds explicit string route aliases without replacing the existing generic and `Type` navigation paths. + +The library remains DI-driven and continues to target `net48` and `net8.0-windows`. The rebuild is intentionally breaking: `RegionService` is deleted rather than retained as a compatibility facade. + +## Goals + +- Move region registration, removal, and lookup out of `PageService` into `IRegionManager` and `RegionManager`. +- Rename the XAML attached-property owner from `RegionService` to `Region` and support `Frame` plus non-`Frame` `ContentControl` hosts. +- Add `IContentService` and `ContentService` for navigating to `FrameworkElement` content other than `Page` and `Window`. +- Add string key navigation to both page and content services through explicit route registration. +- Add a shared `INavigationAware` callback contract for navigated views and view models. +- Preserve generic and `Type` navigation as direct DI resolution paths. +- Keep host behavior extensible through internal adapters so `Panel` and `TabControl` can be added later without rewriting navigation services. +- Add automated tests for both target frameworks and update the README for the rebuilt API. + +## Non-goals + +- Do not implement `Grid`, `StackPanel`, `Panel`, or `TabControl` hosts in this change. +- Do not expose a public adapter plug-in API before those host semantics are defined. +- Do not set or replace a view's `DataContext`. +- Do not infer routes from type names, attributes, reflection scanning, or DI keyed services. +- Do not add back navigation to ordinary `ContentControl` regions. +- Do not add asynchronous navigation, navigation scopes, view caching, or automatic cross-thread dispatch. + +## Breaking Changes + +- Delete `SimpleNavigation.Services.RegionService` and its `RegionRegisted` event. +- Add `SimpleNavigation.Services.Region` as the only attached-property owner. XAML consumers must change `RegionService.RegionName` to `Region.RegionName`. +- Remove `GetRegion` from `IPageService`. +- Remove `RegisterRegion` and the region dictionary from `PageService`. +- Add correctly spelled `GoBack`. Retain `Goback` only as an obsolete forwarding member to avoid an unrelated hard break. +- Change page navigation awareness so the navigated view and its `DataContext` can both receive the shared callback. +- Change invalid navigation configuration from silent no-op behavior to explicit exceptions. + +## Architecture + +### Region declaration + +`Region` is a static attached-property owner. It declares `RegionNameProperty` and the standard `GetRegionName` and `SetRegionName` accessors. It contains no page or content navigation logic. + +An internal host adapter resolver validates each declared host. The resolver checks `Frame` before `ContentControl` because `Frame` inherits `ContentControl`. The built-in host set is: + +- `Frame`, owned by `PageService` and the frame adapter. +- Non-`Frame` `ContentControl`, owned by `ContentService` and the content-control adapter. + +The static declaration layer maintains weak registration records and publishes internal registration changes. A newly created `RegionManager` reads the current weak snapshot before subscribing to subsequent changes. This prevents regions declared during XAML initialization from being lost when DI creates the manager later. + +Changing a region name removes the old registration before adding the new registration. Clearing the property or unloading the host unregisters it. Loading it again restores the registration. Static declarations use weak references so an abandoned visual tree is not retained by the attached-property infrastructure. + +### Region manager + +`IRegionManager` is the public region ownership boundary: + +```csharp +public interface IRegionManager +{ + void RegisterRegion(string regionName, FrameworkElement region); + + bool UnregisterRegion(string regionName, FrameworkElement region); + + FrameworkElement? GetRegion(string regionName); + + TRegion? GetRegion(string regionName) + where TRegion : FrameworkElement; +} +``` + +`RegionManager` is registered as a singleton by `RegisterNavigationService`. It owns named lookup, imports attached-property declarations, and unsubscribes from static declaration notifications when disposed. The element argument on `UnregisterRegion` prevents a stale unload notification from removing a newer host that reused the same name. + +Both attached-property and programmatic registration pass through the same adapter resolver. Programmatic callers cannot register an unsupported host type to bypass `Region` validation. Named manager entries hold weak host references and remove dead entries during lookup or replacement. + +Only one live host may own a region name. A second live host with the same name is a configuration error. A dead weak registration may be cleaned up and replaced. + +### Host adapters + +Adapters remain internal in this release: + +- `FrameRegionAdapter` verifies dispatcher access, calls `Frame.Navigate`, and implements journal back navigation. +- `ContentControlRegionAdapter` verifies dispatcher access and assigns `ContentControl.Content`. It explicitly refuses `Frame` hosts and has no journal capability. + +The adapter resolver is the only component that distinguishes concrete host types. Adding a future `PanelRegionAdapter` or `TabControlRegionAdapter` will extend this resolver and define the new host's presentation semantics without changing `PageService`, `ContentService`, or `RegionManager`. + +`Panel` support must eventually define whether the region owns all children before using `Children.Clear()` and `Children.Add()`. `TabControl` support must define whether navigation creates a tab, replaces the selected tab, or reuses an existing item, including how headers are supplied. Those policies are intentionally deferred. + +### Route registry + +An internal route registry holds two independent, ordinal, case-sensitive maps: + +- Page key to a type assignable to `Page`. +- Content key to a type assignable to `FrameworkElement`, excluding `Page` and `Window`. + +The same key may exist once in each map. A key may not be null, empty, or whitespace. Registering the same key twice in one map throws during service collection configuration, including when both registrations point to the same type. + +The route registry stores only aliases. It does not create instances and does not use Microsoft DI keyed services. This keeps behavior identical for the DI 6 dependency used by `net48` and the DI 8 dependency used by `net8.0-windows`. + +## Public Navigation APIs + +### Page navigation + +```csharp +public interface IPageService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); + + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); +} +``` + +`PageService` accepts only `Frame` regions and only `Page` targets. + +### Content navigation + +```csharp +public interface IContentService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); +} +``` + +`ContentService` accepts only non-`Frame` `ContentControl` regions. A content target may be a `UserControl`, ordinary `ContentControl`, custom control, `Grid`, `StackPanel`, or another `FrameworkElement`; `Page` and `Window` targets are rejected. A `Frame` may be content, but a `Frame` may not be the region host used by `ContentService`. + +### Navigation awareness + +```csharp +public interface INavigationAware +{ + void OnNavigated(DialogParameters? parameters); +} +``` + +`IPageAware` inherits `INavigationAware` and retains its existing `Receive` event. A successful navigation synchronously checks both the target view and its `DataContext`. Every distinct object that implements `INavigationAware` is invoked once. If the view and `DataContext` reference the same instance, it is invoked only once. The callback is invoked even when `parameters` is null. + +The library never assigns `DataContext`. Constructor injection, factories, XAML, and view-to-view-model wiring remain application responsibilities. + +## Service Collection Extensions + +`NavigationExtensions` adds the following overloads: + +```csharp +IServiceCollection AddPage(string key) + where TPage : Page; + +IServiceCollection AddPage() + where TPage : Page + where TViewModel : class; + +IServiceCollection AddPage(string key) + where TPage : Page + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement; + +IServiceCollection AddContent() + where TView : FrameworkElement + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement + where TViewModel : class; +``` + +Registration behavior is: + +- Single-generic key overload: `TryAddTransient` the view and add its route alias. +- Double-generic overload without key: `TryAddTransient` the view and view model, with no route alias. +- Double-generic key overload: `TryAddTransient` the view and view model, then add the route alias. +- `AddContent` performs a runtime guard that rejects `Page` and `Window`, which cannot be expressed as a negative generic constraint. +- Existing application registrations are not replaced. To select a custom lifetime or factory, register it before calling the navigation extension. +- The extension does not set `DataContext` and does not infer view-model interfaces. + +`AddPage` and `AddContent` may be called before or after `RegisterNavigationService` as long as all calls occur before `BuildServiceProvider`. + +## Navigation Data Flow + +The three overload families deliberately have separate resolution paths. + +### Generic navigation + +```csharp +var target = provider.GetRequiredService(); +``` + +Generic navigation never reads the route registry. + +### Type navigation + +```csharp +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +The service validates assignability before resolving. Type navigation never reads the route registry. + +### String key navigation + +```csharp +var targetType = routes.GetRequiredType(key); +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +String navigation first resolves the alias to a type in the appropriate page or content map, then uses the ordinary DI container to create the instance. It does not call `GetRequiredKeyedService`. + +After target resolution, every navigation follows this order: + +1. Validate the region name and target input. +2. Resolve the target instance through the selected path above. +3. Retrieve the named region and require the correct host adapter. +4. Verify access to the host dispatcher. +5. Ask the adapter to present or navigate to the target. +6. If the host accepted navigation, invoke navigation awareness on the view and its `DataContext`. + +For `Frame`, the awareness callback runs synchronously after `Frame.Navigate` returns `true`; it does not wait for `Loaded`, `Navigated`, or `LoadCompleted`. For `ContentControl`, it runs after the `Content` assignment succeeds. + +`GoBack` retrieves the named `Frame`, verifies dispatcher access, and calls `GoBack` only when `CanGoBack` is true. A valid frame with no journal entry is a no-op. `Goback` forwards to `GoBack`. + +## Error Handling + +- Null, empty, or whitespace region names and route keys throw `ArgumentException`. +- An unregistered key throws `KeyNotFoundException` and names the key plus the page or content route category. +- A duplicate key in one route category throws `ArgumentException` while configuring `IServiceCollection`. +- A missing region or a region with the wrong host type throws `InvalidOperationException` and names the region, actual type when available, and expected host type. +- A non-`Page` target passed to `PageService` throws `ArgumentException`. +- A `Page` or `Window` target passed to `ContentService` throws `ArgumentException`. +- A missing DI registration retains the original `GetRequiredService` exception. +- A resolved object that does not match the validated expected base type throws `InvalidOperationException` rather than producing a null navigation target. +- Host, dispatcher, content assignment, navigation, and `INavigationAware` exceptions propagate to the caller. +- If `Frame.Navigate` returns `false`, awareness callbacks are not invoked. +- A second live region with an existing name throws `InvalidOperationException`. +- Attaching `RegionName` to, or programmatically registering, a host without a built-in adapter throws `ArgumentException` and names the unsupported type. +- `GetRegion` returns null when a name is absent; navigation services convert absence into the fail-fast exception above. +- `UnregisterRegion` returns false when the name is absent or belongs to a different element. + +## Threading And Lifetime + +Navigation APIs are synchronous and must be called on the region host's dispatcher thread. Adapters call `VerifyAccess`; the library does not marshal work to another dispatcher. + +`IRegionManager`, `IPageService`, `IContentService`, and the internal route registry are DI singletons. Views and view models added by navigation extensions default to transient through `TryAddTransient`. Existing application registrations control their actual lifetime. + +Region declarations and manager lookups use weak references where ownership is not required. Active WPF hosts unregister on unload, and `RegionManager` releases static subscriptions when disposed. + +## Planned File Structure + +- Delete `Services/RegionService.cs`. +- Create `Services/Region.cs` for the attached property and weak declaration notifications. +- Create `Interface/IRegionManager.cs` and `Common/RegionManager.cs` for region ownership. +- Create focused internal adapter files for `Frame` and `ContentControl` host behavior. +- Create `Interface/IContentService.cs` and `Services/ContentService.cs`. +- Create `Interface/INavigationAware.cs` and update `Interface/IPageAware.cs`. +- Update `Interface/IPageService.cs` and `Services/PageService.cs`. +- Add an internal page/content route registry and route descriptors. +- Update `Extensions/NavigationExtensions.cs` with core service registration and the six route/view overloads. +- Add `Tests/SimpleNavigation.Tests` to the solution. +- Update `README.md` with the rebuilt API, examples, limitations, and migration note. + +## Testing Strategy + +Tests use xUnit and an STA helper for WPF behavior. The test project targets `net48` and `net8.0-windows`. + +Coverage includes: + +- `Region` declaration on `Frame` and ordinary `ContentControl`. +- Rename, clear, unload, reload, weak cleanup, late `RegionManager` creation, and duplicate region detection. +- Programmatic register, unregister, unregistration ownership, untyped lookup, and typed lookup. +- Rejection of unsupported hosts through both attached-property and programmatic registration paths. +- All six `AddPage` and `AddContent` overloads. +- Default transient registration, preservation of an existing singleton, duplicate keys, separate page/content key spaces, and key validation. +- Generic, `Type`, and string navigation for pages and content. +- Proof that generic and `Type` paths do not require a route alias. +- Proof that string paths resolve the dictionary type and then use the ordinary DI registration. +- Content host replacement and frame journal navigation. +- `GoBack`, no-journal behavior, and obsolete `Goback` forwarding. +- Rejection of page targets, window targets, frame content hosts, wrong region hosts, missing regions, invalid types, missing DI registrations, and unknown keys. +- View and `DataContext` awareness, reference de-duplication, null parameters, callback ordering, rejected navigation, and callback exception propagation. +- Dispatcher-thread enforcement. +- Successful Debug and Release builds for both library target frameworks. + +Each production behavior is implemented with a red-green-refactor cycle. The final verification runs the complete test suite and builds the solution for both targets. + +## Documentation + +The README will: + +- Replace every `RegionService.RegionName` example with `Region.RegionName`. +- Document the removal of `RegionService` as a breaking migration. +- Document `IRegionManager`, `IContentService`, and `INavigationAware`. +- Show the six `AddPage` and `AddContent` registration forms. +- Show generic, `Type`, and string key navigation. +- Explain that route aliases map strings to types while DI remains responsible for instances. +- State that the library never assigns `DataContext`. +- State that content regions currently require a non-`Frame` `ContentControl`. +- Explain how the internal adapter boundary permits future `Panel` and `TabControl` support without claiming those hosts work today. From e161c1174450e3c36272a7176a83b43d3885c76b Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 11 Jul 2026 23:07:19 +0800 Subject: [PATCH 02/42] docs: add navigation rebuild implementation plan --- .../plans/2026-07-11-navigation-rebuild.md | 2786 +++++++++++++++++ .../2026-07-11-navigation-rebuild-design.md | 7 +- 2 files changed, 2791 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-11-navigation-rebuild.md diff --git a/docs/superpowers/plans/2026-07-11-navigation-rebuild.md b/docs/superpowers/plans/2026-07-11-navigation-rebuild.md new file mode 100644 index 0000000..e3a62b6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-navigation-rebuild.md @@ -0,0 +1,2786 @@ +# Navigation Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild SimpleNavigation around `RegionManager`, add non-page content navigation and explicit string routes, and remove `RegionService` while preserving DI-driven instance creation on `net48` and `net8.0-windows`. + +**Architecture:** `Region` declares supported WPF hosts, `RegionManager` owns weak named lookup, and internal host adapters isolate `Frame` from ordinary `ContentControl` behavior. `PageService` and `ContentService` resolve views through ordinary Microsoft DI; only string overloads consult separate page/content route maps before resolving the mapped type. + +**Tech Stack:** C# 13, WPF, Microsoft.Extensions.DependencyInjection 6/8, xUnit 2.5.3, Microsoft.NET.Test.Sdk 17.11.1, .NET Framework 4.8, .NET 8 Windows. + +--- + +## File Map + +- `SimpleNavigation.csproj`: exclude the nested test tree from the root SDK project's default items. +- `SimpleNavigation.sln`: include the new test project. +- `Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj`: dual-target WPF test project. +- `Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs`: execute WPF assertions on an STA thread and pump the dispatcher. +- `Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs`: disable test parallelism because region declarations are process-static. +- `Tests/SimpleNavigation.Tests/TestTypes.cs`: focused page, view, view-model, and awareness fixtures. +- `Tests/SimpleNavigation.Tests/RegionManagerTests.cs`: programmatic region ownership tests. +- `Tests/SimpleNavigation.Tests/RegionTests.cs`: attached-property and lifecycle tests. +- `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs`: DI and route registration tests. +- `Tests/SimpleNavigation.Tests/PageServiceTests.cs`: page navigation, awareness, error, and journal tests. +- `Tests/SimpleNavigation.Tests/ContentServiceTests.cs`: content navigation, awareness, and host-boundary tests. +- `Interface/IRegionManager.cs`: public region registry contract. +- `Common/RegionManager.cs`: weak region lookup and attached-declaration synchronization. +- `Common/RegionHostAdapter.cs`: internal host kind, base adapter contract, and ordered resolver. +- `Common/FrameRegionAdapter.cs`: `Frame.Navigate` and journal behavior. +- `Common/ContentControlRegionAdapter.cs`: non-`Frame` content replacement behavior. +- `Services/Region.cs`: attached property plus weak declaration catalog. +- `Common/NavigationRouteRegistry.cs`: immutable page/content key maps. +- `Common/NavigationAwareNotifier.cs`: view and DataContext callback de-duplication. +- `Interface/INavigationAware.cs`: shared navigation callback. +- `Interface/IContentService.cs`: public content navigation contract. +- `Services/ContentService.cs`: DI-driven content navigation. +- `Interface/IPageAware.cs`: inherit the shared awareness contract. +- `Interface/IPageService.cs`: add key navigation and `GoBack`, remove region lookup. +- `Services/PageService.cs`: delegate region ownership and support all three resolution paths. +- `Extensions/NavigationExtensions.cs`: register core services and the six `AddPage`/`AddContent` overloads. +- `README.md`: rebuilt API, examples, migration note, and future-host boundary. +- Delete `Services/RegionService.cs` after `PageService` no longer references it. + +Use this stable SDK invocation throughout because the machine's default .NET 10 RC SDK lacks its matching runtime: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" +``` + +### Task 1: Add The Dual-Target WPF Test Harness + +**Files:** +- Modify: `SimpleNavigation.csproj` +- Modify: `SimpleNavigation.sln` +- Create: `Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj` +- Create: `Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs` +- Create: `Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs` +- Create: `Tests/SimpleNavigation.Tests/SmokeTests.cs` + +- [ ] **Step 1: Exclude tests from the root library's default SDK items** + +Add this property to the main `PropertyGroup` in `SimpleNavigation.csproj`: + +```xml +$(DefaultItemExcludesInProjectFolder);Tests/** +``` + +- [ ] **Step 2: Create the test project** + +Create `Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj` with exactly: + +```xml + + + net48;net8.0-windows + true + enable + enable + 13 + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + +``` + +Add it to the solution: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" sln SimpleNavigation.sln add Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj +``` + +- [ ] **Step 3: Add deterministic STA infrastructure** + +Create `TestInfrastructure/AssemblyInfo.cs`: + +```csharp +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +``` + +Create `TestInfrastructure/StaTest.cs`: + +```csharp +using System.Runtime.ExceptionServices; +using System.Diagnostics; +using System.Threading; +using System.Windows.Threading; + +namespace SimpleNavigation.Tests.TestInfrastructure +{ + internal static class StaTest + { + public static void Run(Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception exception) + { + failure = exception; + } + finally + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + }); + + thread.IsBackground = true; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!thread.Join(TimeSpan.FromSeconds(20))) + throw new TimeoutException("The STA test did not finish within 20 seconds."); + + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } + + public static void PumpDispatcher() + { + var frame = new DispatcherFrame(); + Dispatcher.CurrentDispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(() => frame.Continue = false)); + Dispatcher.PushFrame(frame); + } + + public static void PumpUntil(Func condition) + { + var timeout = Stopwatch.StartNew(); + while (!condition()) + { + if (timeout.Elapsed > TimeSpan.FromSeconds(5)) + throw new TimeoutException("The WPF condition was not reached within 5 seconds."); + PumpDispatcher(); + } + } + } +} +``` + +- [ ] **Step 4: Add and run a baseline smoke test** + +Create `SmokeTests.cs`: + +```csharp +using SimpleNavigation.Common; + +namespace SimpleNavigation.Tests +{ + public class SmokeTests + { + [Fact] + public void DialogParametersRoundTripsAValue() + { + var parameters = new DialogParameters("answer", 42); + + Assert.Equal(42, parameters.Get("answer")); + } + } +} +``` + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" restore SimpleNavigation.sln +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --no-restore +``` + +Expected: both `net48` and `net8.0-windows` pass one test with zero failures. + +- [ ] **Step 5: Commit the test harness** + +```powershell +git add SimpleNavigation.csproj SimpleNavigation.sln Tests +git commit -m "test: add dual-target WPF test harness" +``` + +### Task 2: Move Programmatic Region Ownership Into RegionManager + +**Files:** +- Create: `Interface/IRegionManager.cs` +- Create: `Common/RegionHostAdapter.cs` +- Create: `Common/FrameRegionAdapter.cs` +- Create: `Common/ContentControlRegionAdapter.cs` +- Create: `Common/RegionManager.cs` +- Create: `Tests/SimpleNavigation.Tests/RegionManagerTests.cs` + +- [ ] **Step 1: Write failing public-contract tests** + +Create `RegionManagerTests.cs`: + +```csharp +using SimpleNavigation.Common; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Runtime.CompilerServices; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public class RegionManagerTests + { + [Fact] + public void RegisterGetAndUnregisterPreserveHostIdentity() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var host = new ContentControl(); + + manager.RegisterRegion("main", host); + + Assert.Same(host, manager.GetRegion("main")); + Assert.Same(host, manager.GetRegion("main")); + Assert.Null(manager.GetRegion("main")); + Assert.True(manager.UnregisterRegion("main", host)); + Assert.Null(manager.GetRegion("main")); + }); + } + + [Fact] + public void RegisteringTheSameHostTwiceIsIdempotent() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var host = new Frame(); + + manager.RegisterRegion("main", host); + manager.RegisterRegion("main", host); + + Assert.Same(host, manager.GetRegion("main")); + }); + } + + [Fact] + public void ASecondLiveHostWithTheSameNameIsRejected() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var first = new ContentControl(); + manager.RegisterRegion("main", first); + + var exception = Assert.Throws( + () => manager.RegisterRegion("main", new ContentControl())); + + Assert.Contains("main", exception.Message); + GC.KeepAlive(first); + }); + } + + [Fact] + public void UnregisterDoesNotRemoveAnotherHostsRegistration() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var owner = new ContentControl(); + manager.RegisterRegion("main", owner); + + Assert.False(manager.UnregisterRegion("main", new ContentControl())); + Assert.Same(owner, manager.GetRegion("main")); + }); + } + + [Fact] + public void UnsupportedProgrammaticHostIsRejected() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var exception = Assert.Throws( + () => manager.RegisterRegion("main", new Grid())); + + Assert.Contains(typeof(Grid).FullName!, exception.Message); + }); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void InvalidRegionNameIsRejected(string regionName) + { + StaTest.Run(() => + { + var manager = new RegionManager(); + Assert.Throws( + () => manager.RegisterRegion(regionName, new ContentControl())); + }); + } + + [Fact] + public void ManagerDoesNotKeepAnAbandonedHostAlive() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var weakHost = RegisterTemporaryHost(manager); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(weakHost.IsAlive); + Assert.Null(manager.GetRegion("temporary")); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference RegisterTemporaryHost(RegionManager manager) + { + var host = new ContentControl(); + manager.RegisterRegion("temporary", host); + return new WeakReference(host); + } + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~RegionManagerTests --no-restore +``` + +Expected: build fails because `RegionManager` and `IRegionManager` do not exist. + +- [ ] **Step 3: Add the minimal region contract and host classification** + +Create `Interface/IRegionManager.cs`: + +```csharp +using System.Windows; + +namespace SimpleNavigation.Interface +{ + public interface IRegionManager + { + void RegisterRegion(string regionName, FrameworkElement region); + bool UnregisterRegion(string regionName, FrameworkElement region); + FrameworkElement? GetRegion(string regionName); + TRegion? GetRegion(string regionName) where TRegion : FrameworkElement; + } +} +``` + +Create `Common/RegionHostAdapter.cs`: + +```csharp +using System.Windows; + +namespace SimpleNavigation.Common +{ + internal enum RegionHostKind + { + Page, + Content + } + + internal interface IRegionHostAdapter + { + RegionHostKind Kind { get; } + bool CanHandle(FrameworkElement region); + } + + internal static class RegionHostAdapterResolver + { + private static readonly IRegionHostAdapter[] Adapters = + { + new FrameRegionAdapter(), + new ContentControlRegionAdapter() + }; + + public static IRegionHostAdapter GetRequired(FrameworkElement region) + { + foreach (var adapter in Adapters) + { + if (adapter.CanHandle(region)) + return adapter; + } + + throw new ArgumentException( + $"Region host type '{region.GetType().FullName}' is not supported.", + nameof(region)); + } + } +} +``` + +Create `FrameRegionAdapter.cs`: + +```csharp +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common +{ + internal sealed class FrameRegionAdapter : IRegionHostAdapter + { + public RegionHostKind Kind => RegionHostKind.Page; + + public bool CanHandle(FrameworkElement region) => region is Frame; + } +} +``` + +Create `ContentControlRegionAdapter.cs`: + +```csharp +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common +{ + internal sealed class ContentControlRegionAdapter : IRegionHostAdapter + { + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) => + region is ContentControl && region is not Frame; + } +} +``` + +- [ ] **Step 4: Implement weak programmatic region lookup** + +Create `Common/RegionManager.cs`: + +```csharp +using SimpleNavigation.Interface; +using System.Windows; + +namespace SimpleNavigation.Common +{ + public sealed class RegionManager : IRegionManager + { + private readonly Dictionary regions = + new(StringComparer.Ordinal); + private readonly object syncRoot = new(); + + public void RegisterRegion(string regionName, FrameworkElement region) + { + ValidateName(regionName); + if (region == null) + throw new ArgumentNullException(nameof(region)); + RegionHostAdapterResolver.GetRequired(region); + + lock (syncRoot) + { + if (regions.TryGetValue(regionName, out var existing) && + existing.Region.TryGetTarget(out var existingRegion)) + { + if (ReferenceEquals(existingRegion, region)) + { + existing.IsProgrammatic = true; + return; + } + + throw new InvalidOperationException( + $"Region '{regionName}' is already registered by '{existingRegion.GetType().FullName}'."); + } + + regions[regionName] = new RegionEntry(region) + { + IsProgrammatic = true + }; + } + } + + public bool UnregisterRegion(string regionName, FrameworkElement region) + { + ValidateName(regionName); + if (region == null) + throw new ArgumentNullException(nameof(region)); + + lock (syncRoot) + { + if (!regions.TryGetValue(regionName, out var existing) || + !existing.Region.TryGetTarget(out var existingRegion) || + !ReferenceEquals(existingRegion, region) || + !existing.IsProgrammatic) + return false; + + existing.IsProgrammatic = false; + if (existing.DeclarationTokens.Count == 0) + regions.Remove(regionName); + return true; + } + } + + public FrameworkElement? GetRegion(string regionName) + { + ValidateName(regionName); + + lock (syncRoot) + { + if (!regions.TryGetValue(regionName, out var entry)) + return null; + + if (entry.Region.TryGetTarget(out var region)) + return region; + + regions.Remove(regionName); + return null; + } + } + + public TRegion? GetRegion(string regionName) + where TRegion : FrameworkElement => GetRegion(regionName) as TRegion; + + private static void ValidateName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); + } + + private sealed class RegionEntry + { + public RegionEntry(FrameworkElement region) + { + Region = new WeakReference(region); + } + + public WeakReference Region { get; } + public bool IsProgrammatic { get; set; } + public HashSet DeclarationTokens { get; } = new(); + } + } +} +``` + +- [ ] **Step 5: Run manager tests on both targets** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter FullyQualifiedName~RegionManagerTests --no-restore +``` + +Expected: all `RegionManagerTests` pass for `net48` and `net8.0-windows`. + +- [ ] **Step 6: Commit programmatic region ownership** + +```powershell +git add Interface/IRegionManager.cs Common/RegionHostAdapter.cs Common/FrameRegionAdapter.cs Common/ContentControlRegionAdapter.cs Common/RegionManager.cs Tests/SimpleNavigation.Tests/RegionManagerTests.cs +git commit -m "feat: add region manager" +``` + +### Task 3: Replace Attached Region Registration With Region + +**Files:** +- Create: `Services/Region.cs` +- Modify: `Common/RegionManager.cs` +- Create: `Tests/SimpleNavigation.Tests/RegionTests.cs` + +- [ ] **Step 1: Write failing attached-property lifecycle tests** + +Create `RegionTests.cs` with these tests and a cleanup helper: + +```csharp +using SimpleNavigation.Common; +using SimpleNavigation.Services; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public class RegionTests + { + [Fact] + public void FrameDeclaredBeforeManagerCreationIsReplayed() + { + StaTest.Run(() => + { + var frame = new Frame(); + try + { + Region.SetRegionName(frame, "main"); + using var manager = new RegionManager(); + + Assert.Same(frame, manager.GetRegion("main")); + } + finally + { + frame.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void ContentControlIsSupportedAndGridHostIsRejected() + { + StaTest.Run(() => + { + var content = new ContentControl(); + try + { + Region.SetRegionName(content, "content"); + using var manager = new RegionManager(); + Assert.Same(content, manager.GetRegion("content")); + + Assert.Throws( + () => Region.SetRegionName(new Grid(), "grid")); + } + finally + { + content.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void RenameUnregistersOldNameAndRegistersNewName() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + try + { + Region.SetRegionName(host, "old"); + Region.SetRegionName(host, "new"); + + Assert.Null(manager.GetRegion("old")); + Assert.Same(host, manager.GetRegion("new")); + } + finally + { + host.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void ClearingTheAttachedPropertyUnregistersTheHost() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + Region.SetRegionName(host, "main"); + + host.ClearValue(Region.RegionNameProperty); + + Assert.Null(manager.GetRegion("main")); + }); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AttachedPropertyRejectsInvalidNames(string name) + { + StaTest.Run(() => + Assert.Throws( + () => Region.SetRegionName(new ContentControl(), name))); + } + + [Fact] + public void UnloadRemovesAndLoadRestoresTheRegion() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + try + { + Region.SetRegionName(host, "main"); + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); + Assert.Null(manager.GetRegion("main")); + + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + Assert.Same(host, manager.GetRegion("main")); + } + finally + { + host.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void AttachedUnloadDoesNotRemoveProgrammaticOwnership() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + try + { + manager.RegisterRegion("main", host); + Region.SetRegionName(host, "main"); + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); + + Assert.Same(host, manager.GetRegion("main")); + Assert.True(manager.UnregisterRegion("main", host)); + Assert.Null(manager.GetRegion("main")); + + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + Assert.Same(host, manager.GetRegion("main")); + } + finally + { + host.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void DuplicateAttachedRegionNameIsRejected() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + try + { + Region.SetRegionName(first, "main"); + Assert.Throws( + () => Region.SetRegionName(second, "main")); + } + finally + { + first.ClearValue(Region.RegionNameProperty); + second.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void DeclarationCatalogDoesNotKeepAnAbandonedHostAlive() + { + StaTest.Run(() => + { + var weakHost = DeclareTemporaryHost(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(weakHost.IsAlive); + using var manager = new RegionManager(); + Assert.Null(manager.GetRegion("temporary-declaration")); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference DeclareTemporaryHost() + { + var host = new ContentControl(); + Region.SetRegionName(host, "temporary-declaration"); + return new WeakReference(host); + } + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~RegionTests --no-restore +``` + +Expected: build fails because `SimpleNavigation.Services.Region` does not exist and `RegionManager` is not disposable. + +- [ ] **Step 3: Implement the weak declaration catalog** + +Create `Services/Region.cs` with: + +```csharp +using SimpleNavigation.Common; +using System.Runtime.ExceptionServices; +using System.Windows; +using System.Threading; + +namespace SimpleNavigation.Services +{ + public static class Region + { + private static readonly object SyncRoot = new(); + private static readonly List Declarations = new(); + private static long nextActivationToken; + + internal static event EventHandler? RegistrationChanged; + + public static string? GetRegionName(DependencyObject obj) => + (string?)obj.GetValue(RegionNameProperty); + + public static void SetRegionName(DependencyObject obj, string value) + { + if (obj == null) + throw new ArgumentNullException(nameof(obj)); + ValidateName(value); + if (obj is not FrameworkElement region) + throw new ArgumentException( + "RegionName can only be attached to FrameworkElement instances.", + nameof(obj)); + RegionHostAdapterResolver.GetRequired(region); + ValidateNameAvailable(value, region); + obj.SetValue(RegionNameProperty, value); + } + + public static readonly DependencyProperty RegionNameProperty = + DependencyProperty.RegisterAttached( + "RegionName", + typeof(string), + typeof(Region), + new PropertyMetadata(null, OnRegionNameChanged)); + + internal static IReadOnlyList GetActiveRegistrations() + { + lock (SyncRoot) + { + var result = new List(); + for (var index = Declarations.Count - 1; index >= 0; index--) + { + var declaration = Declarations[index]; + if (!declaration.Region.TryGetTarget(out var region)) + { + Declarations.RemoveAt(index); + continue; + } + + if (declaration.IsActive) + result.Add(new RegionRegistration( + declaration.Name, + region, + declaration.ActivationToken)); + } + + return result; + } + } + + private static void OnRegionNameChanged( + DependencyObject dependencyObject, + DependencyPropertyChangedEventArgs args) + { + if (dependencyObject is not FrameworkElement region) + throw new ArgumentException("RegionName can only be attached to FrameworkElement instances."); + + var newName = args.NewValue as string; + if (newName != null) + { + ValidateName(newName); + RegionHostAdapterResolver.GetRequired(region); + ValidateNameAvailable(newName, region); + } + + var oldName = args.OldValue as string; + if (oldName != null) + { + region.Loaded -= OnLoaded; + region.Unloaded -= OnUnloaded; + RemoveDeclaration(oldName, region); + } + + if (newName == null) + return; + + region.Loaded += OnLoaded; + region.Unloaded += OnUnloaded; + AddOrActivateDeclaration(newName, region); + } + + private static void OnLoaded(object sender, RoutedEventArgs args) + { + var region = (FrameworkElement)sender; + var name = GetRegionName(region); + if (name != null) + AddOrActivateDeclaration(name, region); + } + + private static void OnUnloaded(object sender, RoutedEventArgs args) + { + var region = (FrameworkElement)sender; + var name = GetRegionName(region); + if (name != null) + DeactivateDeclaration(name, region); + } + + private static void AddOrActivateDeclaration(string name, FrameworkElement region) + { + long activationToken; + lock (SyncRoot) + { + EnsureNameAvailableNoLock(name, region); + var declaration = Find(region); + if (declaration == null) + { + declaration = new Declaration( + name, + region, + Interlocked.Increment(ref nextActivationToken)); + Declarations.Add(declaration); + } + else + { + declaration.Name = name; + if (declaration.IsActive) + return; + declaration.IsActive = true; + declaration.ActivationToken = + Interlocked.Increment(ref nextActivationToken); + } + activationToken = declaration.ActivationToken; + } + + Publish(new RegionRegistrationChangedEventArgs( + name, + region, + activationToken, + true)); + } + + private static void DeactivateDeclaration(string name, FrameworkElement region) + { + long activationToken; + lock (SyncRoot) + { + var declaration = Find(region); + if (declaration == null || !declaration.IsActive) + return; + activationToken = declaration.ActivationToken; + declaration.IsActive = false; + } + + Publish(new RegionRegistrationChangedEventArgs( + name, + region, + activationToken, + false)); + } + + private static void RemoveDeclaration(string name, FrameworkElement region) + { + var notify = false; + long activationToken = 0; + lock (SyncRoot) + { + var declaration = Find(region); + if (declaration == null) + return; + notify = declaration.IsActive; + activationToken = declaration.ActivationToken; + Declarations.Remove(declaration); + } + + if (notify) + Publish(new RegionRegistrationChangedEventArgs( + name, + region, + activationToken, + false)); + } + + private static Declaration? Find(FrameworkElement region) + { + foreach (var declaration in Declarations) + { + if (declaration.Region.TryGetTarget(out var target) && + ReferenceEquals(target, region)) + return declaration; + } + + return null; + } + + private static void ValidateNameAvailable( + string name, + FrameworkElement region) + { + lock (SyncRoot) + EnsureNameAvailableNoLock(name, region); + } + + private static void EnsureNameAvailableNoLock( + string name, + FrameworkElement region) + { + for (var index = Declarations.Count - 1; index >= 0; index--) + { + var declaration = Declarations[index]; + if (!declaration.Region.TryGetTarget(out var existing)) + { + Declarations.RemoveAt(index); + continue; + } + + if (declaration.IsActive && + string.Equals(declaration.Name, name, StringComparison.Ordinal) && + !ReferenceEquals(existing, region)) + throw new InvalidOperationException( + $"Region '{name}' is already declared by '{existing.GetType().FullName}'."); + } + } + + private static void Publish(RegionRegistrationChangedEventArgs args) + { + var invocationList = RegistrationChanged?.GetInvocationList(); + if (invocationList == null) + return; + + Exception? firstFailure = null; + foreach (var callback in invocationList) + { + var handler = (EventHandler)callback; + try + { + handler(null, args); + } + catch (Exception exception) + { + firstFailure ??= exception; + } + } + + if (firstFailure != null) + ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + + private static void ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(name)); + } + + private sealed class Declaration + { + public Declaration( + string name, + FrameworkElement region, + long activationToken) + { + Name = name; + Region = new WeakReference(region); + ActivationToken = activationToken; + IsActive = true; + } + + public string Name { get; set; } + public WeakReference Region { get; } + public long ActivationToken { get; set; } + public bool IsActive { get; set; } + } + } + + internal sealed class RegionRegistration + { + public RegionRegistration( + string name, + FrameworkElement region, + long activationToken) + { + Name = name; + Region = region; + ActivationToken = activationToken; + } + + public string Name { get; } + public FrameworkElement Region { get; } + public long ActivationToken { get; } + } + + internal sealed class RegionRegistrationChangedEventArgs : EventArgs + { + public RegionRegistrationChangedEventArgs( + string name, + FrameworkElement region, + long activationToken, + bool isRegistered) + { + Name = name; + Region = region; + ActivationToken = activationToken; + IsRegistered = isRegistered; + } + + public string Name { get; } + public FrameworkElement Region { get; } + public long ActivationToken { get; } + public bool IsRegistered { get; } + } +} +``` + +- [ ] **Step 4: Subscribe RegionManager before importing the snapshot** + +Update `RegionManager` to implement `IDisposable`, subscribe before importing `Region.GetActiveRegistrations()`, and track attached activation tokens independently from public programmatic ownership: + +Add this field beside `syncRoot`: + +```csharp +private bool isDisposed; +``` + +```csharp +public RegionManager() +{ + Region.RegistrationChanged += OnRegistrationChanged; + try + { + foreach (var registration in Region.GetActiveRegistrations()) + RegisterDeclaration( + registration.Name, + registration.Region, + registration.ActivationToken); + } + catch + { + Region.RegistrationChanged -= OnRegistrationChanged; + throw; + } +} + +private void OnRegistrationChanged( + object? sender, + RegionRegistrationChangedEventArgs args) +{ + if (args.IsRegistered) + RegisterDeclaration(args.Name, args.Region, args.ActivationToken); + else + UnregisterDeclaration(args.Name, args.Region, args.ActivationToken); +} + +private void RegisterDeclaration( + string regionName, + FrameworkElement region, + long activationToken) +{ + ValidateName(regionName); + RegionHostAdapterResolver.GetRequired(region); + + lock (syncRoot) + { + if (isDisposed) + return; + + RegionEntry entry; + if (regions.TryGetValue(regionName, out var existing) && + existing.Region.TryGetTarget(out var existingRegion)) + { + if (!ReferenceEquals(existingRegion, region)) + throw new InvalidOperationException( + $"Region '{regionName}' is already registered by '{existingRegion.GetType().FullName}'."); + entry = existing; + } + else + { + entry = new RegionEntry(region); + regions[regionName] = entry; + } + + entry.DeclarationTokens.Add(activationToken); + } +} + +private void UnregisterDeclaration( + string regionName, + FrameworkElement region, + long activationToken) +{ + lock (syncRoot) + { + if (isDisposed) + return; + + if (!regions.TryGetValue(regionName, out var entry) || + !entry.Region.TryGetTarget(out var existingRegion) || + !ReferenceEquals(existingRegion, region)) + return; + + entry.DeclarationTokens.Remove(activationToken); + if (!entry.IsProgrammatic && entry.DeclarationTokens.Count == 0) + regions.Remove(regionName); + } +} + +public void Dispose() +{ + Region.RegistrationChanged -= OnRegistrationChanged; + lock (syncRoot) + { + if (isDisposed) + return; + isDisposed = true; + regions.Clear(); + } +} +``` + +Add `using SimpleNavigation.Services;` and change the declaration to: + +```csharp +public sealed class RegionManager : IRegionManager, IDisposable +``` + +- [ ] **Step 5: Run region lifecycle tests on both targets** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter "FullyQualifiedName~RegionTests|FullyQualifiedName~RegionManagerTests" --no-restore +``` + +Expected: all region tests pass for both target frameworks. + +- [ ] **Step 6: Commit declarative region support** + +```powershell +git add Services/Region.cs Common/RegionManager.cs Tests/SimpleNavigation.Tests/RegionTests.cs +git commit -m "feat: add Region attached registration" +``` + +### Task 4: Add Explicit Page And Content Route Registration + +**Files:** +- Create: `Common/NavigationRouteRegistry.cs` +- Modify: `Extensions/NavigationExtensions.cs` +- Create: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs` + +- [ ] **Step 1: Add reusable test view types** + +Create `TestTypes.cs`: + +```csharp +using SimpleNavigation.Common; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public sealed class FirstPage : Page + { + } + + public sealed class SecondPage : Page + { + } + + public sealed class TestContent : UserControl + { + } + + public sealed class TestViewModel + { + } + +} +``` + +- [ ] **Step 2: Write failing extension tests** + +Create `NavigationExtensionsTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Extensions; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; + +namespace SimpleNavigation.Tests +{ + public class NavigationExtensionsTests + { + [Fact] + public void SingleGenericKeyOverloadsRegisterTransientViews() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddPage("page"); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void DoubleGenericOverloadsRegisterViewAndViewModel() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddPage(); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService()); + var content = provider.GetRequiredService(); + Assert.IsType(content); + Assert.Null(content.DataContext); + Assert.IsType(provider.GetRequiredService()); + }); + } + + [Fact] + public void ExistingSingletonRegistrationIsPreserved() + { + StaTest.Run(() => + { + var page = new FirstPage(); + var services = new ServiceCollection(); + services.AddSingleton(page); + services.AddPage("page"); + using var provider = services.BuildServiceProvider(); + + Assert.Same(page, provider.GetRequiredService()); + }); + } + + [Fact] + public void DuplicateKeyWithinOneCategoryIsRejected() + { + var services = new ServiceCollection(); + services.AddPage("main"); + + Assert.Throws( + () => services.AddPage("main")); + } + + [Fact] + public void TheSameKeyCanExistInPageAndContentMaps() + { + var services = new ServiceCollection(); + + services.AddPage("main"); + services.AddContent("main"); + } + + [Fact] + public void ContentRegistrationRejectsPageAndWindowTypes() + { + var services = new ServiceCollection(); + + Assert.Throws( + () => services.AddContent("page")); + Assert.Throws( + () => services.AddContent("window")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void InvalidRouteKeyIsRejected(string key) + { + Assert.Throws( + () => new ServiceCollection().AddPage(key)); + } + } +} +``` + +- [ ] **Step 3: Run extension tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~NavigationExtensionsTests --no-restore +``` + +Expected: build fails because the six `AddPage` and `AddContent` methods do not exist. + +- [ ] **Step 4: Implement immutable route descriptors and maps** + +Create `Common/NavigationRouteRegistry.cs`: + +```csharp +namespace SimpleNavigation.Common +{ + internal enum NavigationRouteKind + { + Page, + Content + } + + internal sealed class NavigationRouteRegistration + { + public NavigationRouteRegistration( + NavigationRouteKind kind, + string key, + Type targetType) + { + Kind = kind; + Key = key; + TargetType = targetType; + } + + public NavigationRouteKind Kind { get; } + public string Key { get; } + public Type TargetType { get; } + } + + internal sealed class NavigationRouteRegistry + { + private readonly IReadOnlyDictionary pages; + private readonly IReadOnlyDictionary contents; + + public NavigationRouteRegistry(IEnumerable registrations) + { + pages = Build(registrations, NavigationRouteKind.Page); + contents = Build(registrations, NavigationRouteKind.Content); + } + + public Type GetRequiredPageType(string key) => + GetRequired(pages, key, "page"); + + public Type GetRequiredContentType(string key) => + GetRequired(contents, key, "content"); + + private static IReadOnlyDictionary Build( + IEnumerable registrations, + NavigationRouteKind kind) + { + var routes = new Dictionary(StringComparer.Ordinal); + foreach (var registration in registrations.Where(item => item.Kind == kind)) + routes.Add(registration.Key, registration.TargetType); + return routes; + } + + private static Type GetRequired( + IReadOnlyDictionary routes, + string key, + string category) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Route key cannot be null or whitespace.", nameof(key)); + + if (routes.TryGetValue(key, out var targetType)) + return targetType; + + throw new KeyNotFoundException( + $"No {category} route is registered for key '{key}'."); + } + } +} +``` + +- [ ] **Step 5: Add the six extension overloads** + +In `NavigationExtensions`, add the following public methods and private helpers. Preserve the existing `TryAddSingleton` registrations for dialogs and pages. + +```csharp +public static IServiceCollection AddPage( + this IServiceCollection services, + string key) + where TPage : Page +{ + services.TryAddTransient(); + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + return services; +} + +public static IServiceCollection AddPage( + this IServiceCollection services) + where TPage : Page + where TViewModel : class +{ + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddPage( + this IServiceCollection services, + string key) + where TPage : Page + where TViewModel : class +{ + services.AddPage(); + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + return services; +} + +public static IServiceCollection AddContent( + this IServiceCollection services, + string key) + where TView : FrameworkElement +{ + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + return services; +} + +public static IServiceCollection AddContent( + this IServiceCollection services) + where TView : FrameworkElement + where TViewModel : class +{ + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddContent( + this IServiceCollection services, + string key) + where TView : FrameworkElement + where TViewModel : class +{ + services.AddContent(); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + return services; +} + +private static void AddRoute( + IServiceCollection services, + NavigationRouteKind kind, + string key, + Type targetType) +{ + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Route key cannot be null or whitespace.", nameof(key)); + + var duplicate = services.Any(descriptor => + descriptor.ServiceType == typeof(NavigationRouteRegistration) && + descriptor.ImplementationInstance is NavigationRouteRegistration route && + route.Kind == kind && + string.Equals(route.Key, key, StringComparison.Ordinal)); + + if (duplicate) + throw new ArgumentException( + $"A {kind.ToString().ToLowerInvariant()} route with key '{key}' is already registered.", + nameof(key)); + + services.AddSingleton(new NavigationRouteRegistration(kind, key, targetType)); +} + +private static void ValidateContentType(Type targetType) +{ + if (typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Content type '{targetType.FullName}' cannot derive from Page or Window.", + nameof(targetType)); +} +``` + +Add these core registrations inside `RegisterNavigationService`: + +```csharp +serviceCollection.TryAddSingleton(); +serviceCollection.TryAddSingleton(provider => + new NavigationRouteRegistry( + provider.GetServices())); +``` + +Add explicit usings for `System.Windows` and `System.Windows.Controls` so both target frameworks compile. + +- [ ] **Step 6: Run extension and region tests** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter "FullyQualifiedName~NavigationExtensionsTests|FullyQualifiedName~Region" --no-restore +``` + +Expected: all selected tests pass for both target frameworks. + +- [ ] **Step 7: Commit explicit route registration** + +```powershell +git add Common/NavigationRouteRegistry.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/TestTypes.cs Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +git commit -m "feat: add explicit navigation routes" +``` + +### Task 5: Refactor PageService Around RegionManager + +**Files:** +- Create: `Interface/INavigationAware.cs` +- Create: `Common/NavigationAwareNotifier.cs` +- Modify: `Interface/IPageAware.cs` +- Modify: `Interface/IPageService.cs` +- Modify: `Common/RegionHostAdapter.cs` +- Modify: `Common/FrameRegionAdapter.cs` +- Replace: `Services/PageService.cs` +- Delete: `Services/RegionService.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/PageServiceTests.cs` + +- [ ] **Step 1: Add awareness fixtures and failing page navigation tests** + +Add `using SimpleNavigation.Interface;` to `TestTypes.cs`, then add these two types: + +```csharp +public sealed class AwareViewModel : INavigationAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwarePage : Page, INavigationAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class ThrowingAwarePage : Page, INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) => + throw new InvalidOperationException("awareness failed"); +} +``` + +Create `PageServiceTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows.Controls; +using System.Windows.Navigation; + +namespace SimpleNavigation.Tests +{ + public class PageServiceTests + { + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + StaTest.PumpUntil(() => frame.Content is FirstPage); + Assert.IsType(frame.Content); + + service.Navigate("main", typeof(SecondPage)); + StaTest.PumpUntil(() => frame.Content is SecondPage); + Assert.IsType(frame.Content); + }); + } + + [Fact] + public void StringNavigationUsesRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new FirstPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddPage("first"); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", "first"); + + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, expected)); + Assert.Same(expected, frame.Content); + Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main", "First")); + }); + } + + [Fact] + public void ViewAndDataContextReceiveParametersOnceEach() + { + StaTest.Run(() => + { + var viewModel = new AwareViewModel(); + var page = new AwarePage { DataContext = viewModel }; + var parameters = new DialogParameters("id", 7); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(1, page.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, page.Parameters); + Assert.Same(parameters, viewModel.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NullParametersStillTriggerAwarenessAndSameInstanceIsDeduplicated() + { + StaTest.Run(() => + { + var page = new AwarePage(); + page.DataContext = page; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main"); + + Assert.Equal(1, page.CallCount); + Assert.Null(page.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void MissingWrongAndUnknownInputsFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var contentHost = new ContentControl(); + provider.GetRequiredService() + .RegisterRegion("content", contentHost); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("missing")); + Assert.Throws( + () => service.Navigate("content")); + Assert.Throws( + () => service.Navigate("content", typeof(TestContent))); + Assert.Throws( + () => service.Navigate("content", "unknown")); + GC.KeepAlive(contentHost); + }); + } + + [Fact] + public void GoBackUsesTheFrameJournalAndLegacyMethodForwards() + { + StaTest.Run(() => + { + var first = new FirstPage(); + var second = new SecondPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(first); + services.AddSingleton(second); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, second)); + Assert.True(frame.CanGoBack); + + service.GoBack("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + Assert.Same(first, frame.Content); + +#pragma warning disable CS0618 + service.Goback("main"); +#pragma warning restore CS0618 + }); + } + + [Fact] + public void MissingDiRegistrationKeepsTheContainerException() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main")); + + Assert.Contains(typeof(FirstPage).FullName!, exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void AwarenessExceptionPropagatesToTheCaller() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new ThrowingAwarePage()); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main")); + + Assert.Equal("awareness failed", exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NavigationRequiresTheHostDispatcherThread() + { + ServiceProvider? provider = null; + IPageService? service = null; + Frame? frame = null; + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new FirstPage()); + provider = services.BuildServiceProvider(); + frame = RegisterFrame(provider, "main"); + service = provider.GetRequiredService(); + }); + + try + { + Assert.Throws( + () => service!.Navigate("main")); + GC.KeepAlive(frame); + } + finally + { + provider!.Dispose(); + } + } + + private static Frame RegisterFrame(IServiceProvider provider, string name) + { + var frame = new Frame + { + JournalOwnership = JournalOwnership.OwnsJournal, + NavigationUIVisibility = NavigationUIVisibility.Hidden + }; + provider.GetRequiredService().RegisterRegion(name, frame); + return frame; + } + } +} +``` + +- [ ] **Step 2: Run page tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~PageServiceTests --no-restore +``` + +Expected: build fails because `INavigationAware`, string navigation, and `GoBack` do not exist. + +- [ ] **Step 3: Add the shared awareness contract and notifier** + +Create `Interface/INavigationAware.cs`: + +```csharp +using SimpleNavigation.Common; + +namespace SimpleNavigation.Interface +{ + public interface INavigationAware + { + void OnNavigated(DialogParameters? parameters); + } +} +``` + +Change `IPageAware` to inherit `INavigationAware`, retain `Receive`, and remove its duplicate `OnNavigated` declaration: + +```csharp +public interface IPageAware : INavigationAware +{ + event Action? Receive; +} +``` + +Create `Common/NavigationAwareNotifier.cs`: + +```csharp +using SimpleNavigation.Interface; +using System.Windows; + +namespace SimpleNavigation.Common +{ + internal static class NavigationAwareNotifier + { + public static void Notify( + FrameworkElement target, + DialogParameters? parameters) + { + if (target is INavigationAware targetAware) + targetAware.OnNavigated(parameters); + + var dataContext = target.DataContext; + if (dataContext is INavigationAware contextAware && + !ReferenceEquals(dataContext, target)) + contextAware.OnNavigated(parameters); + } + } +} +``` + +- [ ] **Step 4: Add frame navigation capability to the adapter** + +Add this interface to `RegionHostAdapter.cs`: + +```csharp +internal interface IPageRegionHostAdapter : IRegionHostAdapter +{ + bool Navigate(Frame frame, Page page); + bool CanGoBack(Frame frame); + void GoBack(Frame frame); +} +``` + +Add `using System.Windows.Controls;`. Change `FrameRegionAdapter` to implement it: + +```csharp +internal sealed class FrameRegionAdapter : IPageRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Page; + + public bool CanHandle(FrameworkElement region) => region is Frame; + + public bool Navigate(Frame frame, Page page) + { + frame.Dispatcher.VerifyAccess(); + return frame.Navigate(page); + } + + public bool CanGoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + return frame.CanGoBack; + } + + public void GoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + frame.GoBack(); + } +} +``` + +- [ ] **Step 5: Replace the page contract and implementation** + +Replace `IPageService` with the exact public API approved in the design: + +```csharp +using SimpleNavigation.Common; +using System.Windows.Controls; + +namespace SimpleNavigation.Interface +{ + public interface IPageService + { + void Navigate(string regionName, DialogParameters? parameters = null) + where TPage : Page; + void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + void Navigate(string regionName, string key, DialogParameters? parameters = null); + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); + } +} +``` + +Replace `PageService.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Interface; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Services +{ + public class PageService : IPageService + { + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public PageService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page + { + ValidateRegionName(regionName); + NavigateCore(regionName, provider.GetRequiredService(), parameters); + } + + public void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidatePageType(targetType); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } + + public void Navigate( + string regionName, + string key, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredPageType(key); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } + + public void GoBack(string regionName) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter)RegionHostAdapterResolver.GetRequired(frame); + if (adapter.CanGoBack(frame)) + adapter.GoBack(frame); + } + + [Obsolete("Use GoBack instead.")] + public void Goback(string regionName) => GoBack(regionName); + + private void NavigateCore( + string regionName, + Page page, + DialogParameters? parameters) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter)RegionHostAdapterResolver.GetRequired(frame); + if (adapter.Navigate(frame, page)) + NavigationAwareNotifier.Notify(page, parameters); + } + + private Frame GetRequiredFrame(string regionName) + { + ValidateRegionName(regionName); + var region = regionManager.GetRegion(regionName); + if (region is Frame frame) + return frame; + + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a Frame but was '{actual}'."); + } + + private static void ValidatePageType(Type targetType) + { + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + if (!typeof(Page).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Page.", + nameof(targetType)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); + } + } +} +``` + +Delete `Services/RegionService.cs`; the new `Region` type is now the only attached-property owner. + +- [ ] **Step 6: Run page, region, and extension tests** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter "FullyQualifiedName~PageServiceTests|FullyQualifiedName~Region|FullyQualifiedName~NavigationExtensionsTests" --no-restore +``` + +Expected: all selected tests pass on both target frameworks and no source file references `RegionService`. + +- [ ] **Step 7: Commit the page-service refactor** + +```powershell +git add Interface/INavigationAware.cs Interface/IPageAware.cs Interface/IPageService.cs Common/NavigationAwareNotifier.cs Common/RegionHostAdapter.cs Common/FrameRegionAdapter.cs Services/PageService.cs Services/RegionService.cs Tests/SimpleNavigation.Tests/TestTypes.cs Tests/SimpleNavigation.Tests/PageServiceTests.cs +git commit -m "feat: rebuild page navigation around regions" +``` + +### Task 6: Add ContentService For Non-Page FrameworkElements + +**Files:** +- Create: `Interface/IContentService.cs` +- Create: `Services/ContentService.cs` +- Modify: `Common/RegionHostAdapter.cs` +- Modify: `Common/ContentControlRegionAdapter.cs` +- Modify: `Extensions/NavigationExtensions.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/ContentServiceTests.cs` + +- [ ] **Step 1: Add an aware content fixture** + +Append to `TestTypes.cs`: + +```csharp +public sealed class AwareContent : UserControl, INavigationAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} +``` + +- [ ] **Step 2: Write failing content navigation tests** + +Create `ContentServiceTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public class ContentServiceTests + { + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + Assert.IsType(host.Content); + + service.Navigate("main", typeof(Grid)); + Assert.IsType(host.Content); + }); + } + + [Fact] + public void StringNavigationUsesRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new TestContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", "content"); + + Assert.Same(expected, host.Content); + }); + } + + [Fact] + public void ViewAndDataContextReceiveParametersOnceEach() + { + StaTest.Run(() => + { + var viewModel = new AwareViewModel(); + var content = new AwareContent { DataContext = viewModel }; + var parameters = new DialogParameters("id", 9); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(1, content.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, content.Parameters); + Assert.Same(parameters, viewModel.Parameters); + GC.KeepAlive(host); + }); + } + + [Fact] + public void PageWindowAndFrameHostAreRejected() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = new Frame(); + provider.GetRequiredService() + .RegisterRegion("frame", frame); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("frame")); + Assert.Throws( + () => service.Navigate("frame", typeof(Window))); + Assert.Throws( + () => service.Navigate("frame")); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void MissingRegionUnknownKeyAndMissingDiFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddContent("known"); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("missing")); + Assert.Throws( + () => service.Navigate("missing", "unknown")); + Assert.Throws( + () => service.Navigate("missing", typeof(Grid))); + }); + } + + [Fact] + public void DoubleGenericRegistrationWithoutKeyDoesNotCreateARoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddContent(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main", "content")); + GC.KeepAlive(host); + }); + } + + private static ContentControl RegisterContentHost( + IServiceProvider provider, + string name) + { + var host = new ContentControl(); + provider.GetRequiredService().RegisterRegion(name, host); + return host; + } + } +} +``` + +- [ ] **Step 3: Run content tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~ContentServiceTests --no-restore +``` + +Expected: build fails because `IContentService` and `ContentService` do not exist. + +- [ ] **Step 4: Add the public content contract** + +Create `Interface/IContentService.cs`: + +```csharp +using SimpleNavigation.Common; +using System.Windows; + +namespace SimpleNavigation.Interface +{ + public interface IContentService + { + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); + } +} +``` + +- [ ] **Step 5: Add content presentation capability to the adapter** + +Add to `RegionHostAdapter.cs`: + +```csharp +internal interface IContentRegionHostAdapter : IRegionHostAdapter +{ + void Present(ContentControl host, FrameworkElement content); +} +``` + +Change `ContentControlRegionAdapter` to: + +```csharp +internal sealed class ContentControlRegionAdapter : IContentRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) => + region is ContentControl && region is not Frame; + + public void Present(ContentControl host, FrameworkElement content) + { + host.Dispatcher.VerifyAccess(); + host.Content = content; + } +} +``` + +- [ ] **Step 6: Implement ContentService** + +Create `Services/ContentService.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Interface; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Services +{ + public class ContentService : IContentService + { + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public ContentService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement + { + ValidateRegionName(regionName); + ValidateContentType(typeof(TContent)); + NavigateCore( + regionName, + provider.GetRequiredService(), + parameters); + } + + public void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + public void Navigate( + string regionName, + string key, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredContentType(key); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + private void NavigateCore( + string regionName, + FrameworkElement content, + DialogParameters? parameters) + { + var host = GetRequiredHost(regionName); + var adapter = (IContentRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(host); + adapter.Present(host, content); + NavigationAwareNotifier.Notify(content, parameters); + } + + private ContentControl GetRequiredHost(string regionName) + { + var region = regionManager.GetRegion(regionName); + if (region is ContentControl host && region is not Frame) + return host; + + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a non-Frame ContentControl but was '{actual}'."); + } + + private static void ValidateContentType(Type targetType) + { + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + if (!typeof(FrameworkElement).IsAssignableFrom(targetType) || + typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Target type '{targetType.FullName}' must be a non-Page, non-Window FrameworkElement.", + nameof(targetType)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); + } + } +} +``` + +- [ ] **Step 7: Register ContentService and run the navigation suite** + +Add this line to `RegisterNavigationService`: + +```csharp +serviceCollection.TryAddSingleton(); +``` + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --no-restore +``` + +Expected: every test passes for both `net48` and `net8.0-windows`. + +- [ ] **Step 8: Commit content navigation** + +```powershell +git add Interface/IContentService.cs Services/ContentService.cs Common/RegionHostAdapter.cs Common/ContentControlRegionAdapter.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/TestTypes.cs Tests/SimpleNavigation.Tests/ContentServiceTests.cs +git commit -m "feat: add content navigation service" +``` + +### Task 7: Update Documentation And Migration Guidance + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the project structure and feature summary** + +Update the README to describe four public capabilities: `PageService`, `ContentService`, `DialogService`, and `RegionManager`. Replace `RegionService.cs` in the tree with these entries: + +```text +Interface/ + IRegionManager.cs + IPageService.cs + IContentService.cs + INavigationAware.cs +Services/ + Region.cs + PageService.cs + ContentService.cs + DialogService.cs +Common/ + RegionManager.cs +``` + +- [ ] **Step 2: Replace XAML region examples** + +Use `Region.RegionName` and show both supported host types: + +```xml + + + + + + + + + + + + +``` + +State directly below the example that `PageService` requires a `Frame`, while `ContentService` requires a non-`Frame` `ContentControl`. + +- [ ] **Step 3: Document DI and route registration without DataContext ownership** + +Add this complete registration example: + +```csharp +var services = new ServiceCollection(); +services.RegisterNavigationService(); + +// Ordinary DI registration is enough for generic and Type navigation. +services.AddTransient(); +services.AddSingleton(); + +// Route helpers optionally register the view/view model and a string alias. +services.AddPage("settings"); +services.AddPage("reports"); +services.AddContent(); +services.AddContent("status"); +``` + +Explain that the helpers use `TryAddTransient`, preserve registrations made earlier, and never set `DataContext`. + +- [ ] **Step 4: Document all three navigation paths** + +Add page examples: + +```csharp +pageService.Navigate("Pages"); +pageService.Navigate("Pages", typeof(HomePage)); +pageService.Navigate("Pages", "settings"); +pageService.GoBack("Pages"); +``` + +Add content examples: + +```csharp +contentService.Navigate("Content"); +contentService.Navigate("Content", typeof(DashboardView)); +contentService.Navigate("Content", "status"); +``` + +State that only the string overload reads the route dictionary; all three paths resolve the final type through ordinary DI. + +- [ ] **Step 5: Document awareness and migration** + +Use this awareness example: + +```csharp +public sealed class StatusViewModel : INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) + { + var id = parameters?.Get("id"); + } +} +``` + +Add a breaking migration note with these exact mappings: + +```text +RegionService.RegionName -> Region.RegionName +IPageService.GetRegion(...) -> IRegionManager.GetRegion(...) +IPageService.Goback(...) -> IPageService.GoBack(...) +``` + +State that `RegionService` is deleted, `Goback` remains only as an obsolete forwarding method, and compiled XAML/BAML must be rebuilt. + +- [ ] **Step 6: Document future host extension limits** + +State that `Grid` and `StackPanel` may be navigation targets today but are not region hosts. Explain that future panel and tab adapters require explicit child ownership, tab creation, selection, reuse, and header policies before those hosts can be enabled. + +- [ ] **Step 7: Verify README names and examples** + +Run: + +```powershell +rg -n "RegionService\.RegionName|pageService\.GetRegion|\.Goback\(" README.md +rg -n "Region\.RegionName|AddPage|AddContent|IContentService|INavigationAware|IRegionManager" README.md +``` + +Expected: the first command finds only the intentional migration mapping, and the second command finds every rebuilt API section. + +- [ ] **Step 8: Commit documentation** + +```powershell +git add README.md +git commit -m "docs: document rebuilt navigation APIs" +``` + +### Task 8: Complete Cross-Target Verification And Review + +**Files:** +- Modify only files required by failures or review findings from Tasks 1-7. + +- [ ] **Step 1: Verify no production reference to RegionService remains** + +Run: + +```powershell +rg -n "RegionService" Common Extensions Interface Services SimpleNavigation.csproj +``` + +Expected: exit code 1 with no matches. + +- [ ] **Step 2: Restore and build Debug once** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" restore SimpleNavigation.sln +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Debug --no-restore --nologo +``` + +Expected: both library targets and both test targets build with zero errors. Investigate and remove warnings introduced by the rebuild. + +- [ ] **Step 3: Run each target's complete test suite without rebuilding** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -c Debug -f net48 --no-build --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -c Debug -f net8.0-windows --no-build --nologo +``` + +Expected: all tests pass on each target with zero failures. + +- [ ] **Step 4: Build the complete Release solution and package** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Release --no-restore --nologo +``` + +Expected: `net48` and `net8.0-windows` library outputs, both test assemblies, and the NuGet package build successfully with zero errors. + +- [ ] **Step 5: Audit the public surface and worktree** + +Run: + +```powershell +rg -n "public (class|interface|static class)|public static IServiceCollection" Common Extensions Interface Services -g "*.cs" +git diff --check +git status --short --branch +``` + +Expected: public APIs match the approved spec, `git diff --check` is empty, and only intentional implementation changes remain. + +- [ ] **Step 6: Request focused code review** + +Dispatch a reviewer with the design spec, this plan, the pre-implementation commit, and current HEAD. Require findings on region ownership tokens, static subscription disposal, both route namespaces, DI lifetime preservation, dispatcher access, awareness ordering, and public API completeness. + +Expected: no Critical or Important findings remain unresolved. + +- [ ] **Step 7: Apply review fixes through TDD and re-run full verification** + +For each valid finding, first add a focused failing test to the owning test file, run it to verify the expected failure, apply the smallest production fix, and rerun the focused plus complete suites from Steps 2-4. + +- [ ] **Step 8: Commit review fixes when needed** + +If review required changes, stage only those tests and production files and commit: + +```powershell +git commit -m "fix: address navigation rebuild review" +``` + +If no files changed, do not create an empty commit. diff --git a/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md index 441bcb6..3114ea9 100644 --- a/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md +++ b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md @@ -51,9 +51,9 @@ An internal host adapter resolver validates each declared host. The resolver che - `Frame`, owned by `PageService` and the frame adapter. - Non-`Frame` `ContentControl`, owned by `ContentService` and the content-control adapter. -The static declaration layer maintains weak registration records and publishes internal registration changes. A newly created `RegionManager` reads the current weak snapshot before subscribing to subsequent changes. This prevents regions declared during XAML initialization from being lost when DI creates the manager later. +The static declaration layer maintains weak registration records and publishes internal registration changes. A newly created `RegionManager` subscribes to changes before importing the current weak snapshot; registering the same name and host twice is idempotent. This ordering prevents both a snapshot-to-subscription race and regions declared during XAML initialization from being lost when DI creates the manager later. -Changing a region name removes the old registration before adding the new registration. Clearing the property or unloading the host unregisters it. Loading it again restores the registration. Static declarations use weak references so an abandoned visual tree is not retained by the attached-property infrastructure. +Changing a region name removes the old registration before adding the new registration. Clearing the property or unloading the host unregisters its attached-property ownership. Loading it again restores that ownership with a new activation token, so a delayed notification from an older load cycle cannot remove the new registration. Static declarations use weak references so an abandoned visual tree is not retained by the attached-property infrastructure. ### Region manager @@ -77,6 +77,8 @@ public interface IRegionManager Both attached-property and programmatic registration pass through the same adapter resolver. Programmatic callers cannot register an unsupported host type to bypass `Region` validation. Named manager entries hold weak host references and remove dead entries during lookup or replacement. +Programmatic and attached-property ownership are tracked independently for the same name and host. Public `RegisterRegion` and `UnregisterRegion` add or remove only programmatic ownership. Internal declaration notifications add or remove a specific activation token. A region remains available while either ownership source is active. This prevents `Unloaded` from accidentally removing a programmatically registered region and prevents a stale unload token from removing a reloaded declaration. + Only one live host may own a region name. A second live host with the same name is a configuration error. A dead weak registration may be cleaned up and replaced. ### Host adapters @@ -298,6 +300,7 @@ Coverage includes: - `Region` declaration on `Frame` and ordinary `ContentControl`. - Rename, clear, unload, reload, weak cleanup, late `RegionManager` creation, and duplicate region detection. - Programmatic register, unregister, unregistration ownership, untyped lookup, and typed lookup. +- Mixed programmatic and attached ownership, including unload/reload activation-token ordering. - Rejection of unsupported hosts through both attached-property and programmatic registration paths. - All six `AddPage` and `AddContent` overloads. - Default transient registration, preservation of an existing singleton, duplicate keys, separate page/content key spaces, and key validation. From 1eb12bf618737ff28d63cdd7ddca927a60ea57de Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 11 Jul 2026 23:21:26 +0800 Subject: [PATCH 03/42] test: add dual-target WPF test harness --- SimpleNavigation.csproj | 1 + SimpleNavigation.sln | 33 ++++++- .../SimpleNavigation.Tests.csproj | 30 +++++++ Tests/SimpleNavigation.Tests/SmokeTests.cs | 14 +++ .../TestInfrastructure/AssemblyInfo.cs | 3 + .../TestInfrastructure/StaTest.cs | 87 +++++++++++++++++++ 6 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj create mode 100644 Tests/SimpleNavigation.Tests/SmokeTests.cs create mode 100644 Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs create mode 100644 Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index 99a88a9..29ade99 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -11,6 +11,7 @@ 1.0.1 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. + $(DefaultItemExcludesInProjectFolder);Tests/** diff --git a/SimpleNavigation.sln b/SimpleNavigation.sln index 48ccf79..c51f1e2 100644 --- a/SimpleNavigation.sln +++ b/SimpleNavigation.sln @@ -1,24 +1,55 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36401.2 d17.14 +VisualStudioVersion = 17.14.36401.2 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleNavigation", "SimpleNavigation.csproj", "{78216B02-64FB-4739-A012-3422014F0B26}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleNavigation.Tests", "Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj", "{BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {78216B02-64FB-4739-A012-3422014F0B26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x64.ActiveCfg = Debug|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x64.Build.0 = Debug|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x86.ActiveCfg = Debug|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x86.Build.0 = Debug|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Release|Any CPU.ActiveCfg = Release|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Release|Any CPU.Build.0 = Release|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Release|x64.ActiveCfg = Release|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Release|x64.Build.0 = Release|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Release|x86.ActiveCfg = Release|Any CPU + {78216B02-64FB-4739-A012-3422014F0B26}.Release|x86.Build.0 = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x64.ActiveCfg = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x64.Build.0 = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x86.ActiveCfg = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x86.Build.0 = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|Any CPU.Build.0 = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x64.ActiveCfg = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x64.Build.0 = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x86.ActiveCfg = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BCCE3619-D9CD-41B6-99FD-C9B8BA074CD4} EndGlobalSection diff --git a/Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj b/Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj new file mode 100644 index 0000000..7a689e6 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj @@ -0,0 +1,30 @@ + + + + net48;net8.0-windows + true + enable + enable + 13 + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Tests/SimpleNavigation.Tests/SmokeTests.cs b/Tests/SimpleNavigation.Tests/SmokeTests.cs new file mode 100644 index 0000000..c8f07f5 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/SmokeTests.cs @@ -0,0 +1,14 @@ +using SimpleNavigation.Common; + +namespace SimpleNavigation.Tests; + +public sealed class SmokeTests +{ + [Fact] + public void DialogParameters_RoundTripsValue() + { + var parameters = new DialogParameters("answer", 42); + + Assert.Equal(42, parameters.Get("answer")); + } +} diff --git a/Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs b/Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs b/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs new file mode 100644 index 0000000..8ea55aa --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows.Threading; + +namespace SimpleNavigation.Tests.TestInfrastructure; + +internal static class StaTest +{ + private static readonly TimeSpan ThreadTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan PumpTimeout = TimeSpan.FromSeconds(5); + + public static void Run(Action action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + ExceptionDispatchInfo? capturedException = null; + + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception exception) + { + capturedException = ExceptionDispatchInfo.Capture(exception); + } + finally + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + }) + { + IsBackground = true, + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + if (!thread.Join(ThreadTimeout)) + { + throw new TimeoutException($"The STA test thread did not finish within {ThreadTimeout.TotalSeconds} seconds."); + } + + capturedException?.Throw(); + } + + public static void PumpDispatcher() + { + var frame = new DispatcherFrame(); + + Dispatcher.CurrentDispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new DispatcherOperationCallback(_ => + { + frame.Continue = false; + return null; + }), + null); + + Dispatcher.PushFrame(frame); + } + + public static void PumpUntil(Func condition) + { + if (condition is null) + { + throw new ArgumentNullException(nameof(condition)); + } + + var stopwatch = Stopwatch.StartNew(); + + while (!condition()) + { + if (stopwatch.Elapsed >= PumpTimeout) + { + throw new TimeoutException($"The dispatcher condition was not met within {PumpTimeout.TotalSeconds} seconds."); + } + + PumpDispatcher(); + } + } +} From 8c363976171f7a9b145af8ad6341614345dff523 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 11 Jul 2026 23:40:45 +0800 Subject: [PATCH 04/42] test: harden WPF test harness --- SimpleNavigation.sln | 22 +------ Tests/SimpleNavigation.Tests/SmokeTests.cs | 58 +++++++++++++++++++ .../TestInfrastructure/StaTest.cs | 36 +++++++++++- 3 files changed, 92 insertions(+), 24 deletions(-) diff --git a/SimpleNavigation.sln b/SimpleNavigation.sln index c51f1e2..5ba1bd4 100644 --- a/SimpleNavigation.sln +++ b/SimpleNavigation.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36401.2 +VisualStudioVersion = 17.14.36401.2 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleNavigation", "SimpleNavigation.csproj", "{78216B02-64FB-4739-A012-3422014F0B26}" EndProject @@ -12,37 +12,17 @@ EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {78216B02-64FB-4739-A012-3422014F0B26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Debug|Any CPU.Build.0 = Debug|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x64.ActiveCfg = Debug|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x64.Build.0 = Debug|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x86.ActiveCfg = Debug|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Debug|x86.Build.0 = Debug|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Release|Any CPU.ActiveCfg = Release|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Release|Any CPU.Build.0 = Release|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Release|x64.ActiveCfg = Release|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Release|x64.Build.0 = Release|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Release|x86.ActiveCfg = Release|Any CPU - {78216B02-64FB-4739-A012-3422014F0B26}.Release|x86.Build.0 = Release|Any CPU {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x64.ActiveCfg = Debug|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x64.Build.0 = Debug|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x86.ActiveCfg = Debug|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|x86.Build.0 = Debug|Any CPU {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|Any CPU.ActiveCfg = Release|Any CPU {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|Any CPU.Build.0 = Release|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x64.ActiveCfg = Release|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x64.Build.0 = Release|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x86.ActiveCfg = Release|Any CPU - {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Tests/SimpleNavigation.Tests/SmokeTests.cs b/Tests/SimpleNavigation.Tests/SmokeTests.cs index c8f07f5..8f46495 100644 --- a/Tests/SimpleNavigation.Tests/SmokeTests.cs +++ b/Tests/SimpleNavigation.Tests/SmokeTests.cs @@ -1,4 +1,7 @@ +using System.Threading; +using System.Windows.Threading; using SimpleNavigation.Common; +using SimpleNavigation.Tests.TestInfrastructure; namespace SimpleNavigation.Tests; @@ -11,4 +14,59 @@ public void DialogParameters_RoundTripsValue() Assert.Equal(42, parameters.Get("answer")); } + + [Fact] + public void StaTest_Run_ExecutesOnStaThread() + { + ApartmentState? apartmentState = null; + + StaTest.Run(() => apartmentState = Thread.CurrentThread.GetApartmentState()); + + Assert.Equal(ApartmentState.STA, apartmentState); + } + + [Fact] + public void StaTest_PumpUntil_ProcessesQueuedDispatcherWork() + { + var workProcessed = false; + + StaTest.Run(() => + { + Dispatcher.CurrentDispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => workProcessed = true)); + + StaTest.PumpUntil(() => workProcessed); + }); + + Assert.True(workProcessed); + } + + [Fact] + public void StaTest_PumpUntil_TimesOutWhenIdleWorkIsStarved() + { + var exception = Assert.Throws(() => StaTest.Run(() => + { + var dispatcher = Dispatcher.CurrentDispatcher; + DispatcherOperationCallback? keepDispatcherBusy = null; + + keepDispatcherBusy = _ => + { + dispatcher.BeginInvoke( + DispatcherPriority.Normal, + keepDispatcherBusy!, + null); + return null; + }; + + dispatcher.BeginInvoke( + DispatcherPriority.Normal, + keepDispatcherBusy, + null); + + StaTest.PumpUntil(() => false); + })); + + Assert.StartsWith("The dispatcher condition was not met", exception.Message); + } } diff --git a/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs b/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs index 8ea55aa..debda42 100644 --- a/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs +++ b/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs @@ -9,6 +9,7 @@ internal static class StaTest { private static readonly TimeSpan ThreadTimeout = TimeSpan.FromSeconds(20); private static readonly TimeSpan PumpTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan PumpSlice = TimeSpan.FromMilliseconds(50); public static void Run(Action action) { @@ -31,7 +32,14 @@ public static void Run(Action action) } finally { - Dispatcher.CurrentDispatcher.InvokeShutdown(); + try + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + catch (Exception exception) + { + capturedException ??= ExceptionDispatchInfo.Capture(exception); + } } }) { @@ -51,9 +59,10 @@ public static void Run(Action action) public static void PumpDispatcher() { + var dispatcher = Dispatcher.CurrentDispatcher; var frame = new DispatcherFrame(); - Dispatcher.CurrentDispatcher.BeginInvoke( + var idleOperation = dispatcher.BeginInvoke( DispatcherPriority.ApplicationIdle, new DispatcherOperationCallback(_ => { @@ -62,7 +71,28 @@ public static void PumpDispatcher() }), null); - Dispatcher.PushFrame(frame); + var timer = new DispatcherTimer(DispatcherPriority.Send, dispatcher) + { + Interval = PumpSlice, + }; + EventHandler stopFrame = (_, _) => frame.Continue = false; + timer.Tick += stopFrame; + timer.Start(); + + try + { + Dispatcher.PushFrame(frame); + } + finally + { + timer.Stop(); + timer.Tick -= stopFrame; + + if (idleOperation.Status == DispatcherOperationStatus.Pending) + { + idleOperation.Abort(); + } + } } public static void PumpUntil(Func condition) From 129828ae6c72171c4b73998bf367074b62d60f3d Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 11 Jul 2026 23:52:27 +0800 Subject: [PATCH 05/42] feat: add region manager --- Common/ContentControlRegionAdapter.cs | 14 ++ Common/FrameRegionAdapter.cs | 14 ++ Common/RegionHostAdapter.cs | 16 ++ Common/RegionManager.cs | 116 +++++++++++++ Interface/IRegionManager.cs | 14 ++ .../RegionManagerTests.cs | 161 ++++++++++++++++++ 6 files changed, 335 insertions(+) create mode 100644 Common/ContentControlRegionAdapter.cs create mode 100644 Common/FrameRegionAdapter.cs create mode 100644 Common/RegionHostAdapter.cs create mode 100644 Common/RegionManager.cs create mode 100644 Interface/IRegionManager.cs create mode 100644 Tests/SimpleNavigation.Tests/RegionManagerTests.cs diff --git a/Common/ContentControlRegionAdapter.cs b/Common/ContentControlRegionAdapter.cs new file mode 100644 index 0000000..f23f585 --- /dev/null +++ b/Common/ContentControlRegionAdapter.cs @@ -0,0 +1,14 @@ +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common; + +internal sealed class ContentControlRegionAdapter : IRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) + { + return region is ContentControl && region is not Frame; + } +} diff --git a/Common/FrameRegionAdapter.cs b/Common/FrameRegionAdapter.cs new file mode 100644 index 0000000..f6c9f4c --- /dev/null +++ b/Common/FrameRegionAdapter.cs @@ -0,0 +1,14 @@ +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common; + +internal sealed class FrameRegionAdapter : IRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Page; + + public bool CanHandle(FrameworkElement region) + { + return region is Frame; + } +} diff --git a/Common/RegionHostAdapter.cs b/Common/RegionHostAdapter.cs new file mode 100644 index 0000000..8de5731 --- /dev/null +++ b/Common/RegionHostAdapter.cs @@ -0,0 +1,16 @@ +using System.Windows; + +namespace SimpleNavigation.Common; + +internal enum RegionHostKind +{ + Page, + Content, +} + +internal interface IRegionHostAdapter +{ + RegionHostKind Kind { get; } + + bool CanHandle(FrameworkElement region); +} diff --git a/Common/RegionManager.cs b/Common/RegionManager.cs new file mode 100644 index 0000000..5ed5679 --- /dev/null +++ b/Common/RegionManager.cs @@ -0,0 +1,116 @@ +using System.Windows; +using SimpleNavigation.Interface; + +namespace SimpleNavigation.Common; + +public sealed class RegionManager : IRegionManager +{ + private static readonly IRegionHostAdapter[] HostAdapters = + { + new FrameRegionAdapter(), + new ContentControlRegionAdapter(), + }; + + private readonly Dictionary> regions = + new(StringComparer.Ordinal); + + public void RegisterRegion(string regionName, FrameworkElement region) + { + ValidateRegionName(regionName); + + if (region == null) + { + throw new ArgumentNullException(nameof(region)); + } + + ResolveHostAdapter(region); + + if (regions.TryGetValue(regionName, out var existingReference) && + existingReference.TryGetTarget(out var existingRegion)) + { + if (ReferenceEquals(existingRegion, region)) + { + return; + } + + throw new InvalidOperationException($"Region '{regionName}' is already registered."); + } + + regions[regionName] = new WeakReference(region); + } + + public bool UnregisterRegion(string regionName, FrameworkElement region) + { + ValidateRegionName(regionName); + + if (region == null) + { + throw new ArgumentNullException(nameof(region)); + } + + if (!regions.TryGetValue(regionName, out var existingReference)) + { + return false; + } + + if (!existingReference.TryGetTarget(out var existingRegion)) + { + regions.Remove(regionName); + return false; + } + + if (!ReferenceEquals(existingRegion, region)) + { + return false; + } + + return regions.Remove(regionName); + } + + public FrameworkElement? GetRegion(string regionName) + { + ValidateRegionName(regionName); + + if (!regions.TryGetValue(regionName, out var regionReference)) + { + return null; + } + + if (regionReference.TryGetTarget(out var region)) + { + return region; + } + + regions.Remove(regionName); + return null; + } + + public TRegion? GetRegion(string regionName) where TRegion : FrameworkElement + { + return GetRegion(regionName) as TRegion; + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException("Region name cannot be null, empty, or whitespace.", nameof(regionName)); + } + } + + private static IRegionHostAdapter ResolveHostAdapter(FrameworkElement region) + { + foreach (var adapter in HostAdapters) + { + if (adapter.CanHandle(region)) + { + return adapter; + } + } + + var regionType = region.GetType(); + throw new ArgumentException( + $"Region host type '{regionType.FullName}' is not supported.", + nameof(region)); + } +} diff --git a/Interface/IRegionManager.cs b/Interface/IRegionManager.cs new file mode 100644 index 0000000..ea91db9 --- /dev/null +++ b/Interface/IRegionManager.cs @@ -0,0 +1,14 @@ +using System.Windows; + +namespace SimpleNavigation.Interface; + +public interface IRegionManager +{ + void RegisterRegion(string regionName, FrameworkElement region); + + bool UnregisterRegion(string regionName, FrameworkElement region); + + FrameworkElement? GetRegion(string regionName); + + TRegion? GetRegion(string regionName) where TRegion : FrameworkElement; +} diff --git a/Tests/SimpleNavigation.Tests/RegionManagerTests.cs b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs new file mode 100644 index 0000000..2a0c471 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs @@ -0,0 +1,161 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using SimpleNavigation.Common; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class RegionManagerTests +{ + [Fact] + public void RegisterRegion_ContentHost_RoundTripsAndUnregistersExactInstance() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var region = new ContentControl(); + + manager.RegisterRegion("Main", region); + + Assert.Same(region, manager.GetRegion("Main")); + Assert.Same(region, manager.GetRegion("Main")); + Assert.Null(manager.GetRegion("Main")); + Assert.True(manager.UnregisterRegion("Main", region)); + Assert.Null(manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_FrameHost_RoundTripsTypedAndUntyped() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var region = new Frame(); + + manager.RegisterRegion("Pages", region); + + Assert.Same(region, manager.GetRegion("Pages")); + Assert.Same(region, manager.GetRegion("Pages")); + }); + } + + [Fact] + public void RegisterRegion_SameHostRepeated_IsIdempotent() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var region = new ContentControl(); + + manager.RegisterRegion("Main", region); + manager.RegisterRegion("Main", region); + + Assert.Same(region, manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_DifferentLiveHostForSameName_Throws() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var first = new ContentControl(); + var second = new ContentControl(); + manager.RegisterRegion("Main", first); + + var exception = Assert.Throws( + () => manager.RegisterRegion("Main", second)); + + Assert.Contains("Main", exception.Message); + GC.KeepAlive(first); + }); + } + + [Fact] + public void UnregisterRegion_WrongHost_ReturnsFalseAndPreservesOwner() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var owner = new ContentControl(); + var other = new ContentControl(); + manager.RegisterRegion("Main", owner); + + Assert.False(manager.UnregisterRegion("Main", other)); + Assert.Same(owner, manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_UnsupportedHost_ThrowsWithHostType() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + + var exception = Assert.Throws( + () => manager.RegisterRegion("Main", new Grid())); + + Assert.Contains(typeof(Grid).FullName!, exception.Message); + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RegisterRegion_InvalidName_Throws(string? regionName) + { + StaTest.Run(() => + { + var manager = new RegionManager(); + + Assert.Throws( + () => manager.RegisterRegion(regionName!, new ContentControl())); + }); + } + + [Fact] + public void RegisterRegion_NullHost_Throws() + { + var manager = new RegionManager(); + + var exception = Assert.Throws( + () => manager.RegisterRegion("Main", null!)); + + Assert.Equal("region", exception.ParamName); + } + + [Fact] + public void RegionManager_DoesNotKeepAbandonedHostAlive() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var weakRegion = RegisterAbandonedRegion(manager, "Main"); + + ForceGarbageCollection(); + + Assert.False(weakRegion.IsAlive); + Assert.Null(manager.GetRegion("Main")); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference RegisterAbandonedRegion(RegionManager manager, string regionName) + { + var region = new ContentControl(); + manager.RegisterRegion(regionName, region); + return new WeakReference(region); + } + + private static void ForceGarbageCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } +} From 498f3eaafe970b4ddbb7f059b316e505ec57d95d Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 00:09:16 +0800 Subject: [PATCH 06/42] fix: harden region manager --- Common/RegionHostAdapter.cs | 30 +++ Common/RegionManager.cs | 92 ++++----- .../RegionManagerTests.cs | 180 +++++++++++++++++- 3 files changed, 245 insertions(+), 57 deletions(-) diff --git a/Common/RegionHostAdapter.cs b/Common/RegionHostAdapter.cs index 8de5731..88193b9 100644 --- a/Common/RegionHostAdapter.cs +++ b/Common/RegionHostAdapter.cs @@ -14,3 +14,33 @@ internal interface IRegionHostAdapter bool CanHandle(FrameworkElement region); } + +internal static class RegionHostAdapterResolver +{ + private static readonly IRegionHostAdapter[] HostAdapters = + { + new FrameRegionAdapter(), + new ContentControlRegionAdapter(), + }; + + public static IRegionHostAdapter GetRequired(FrameworkElement region) + { + if (region == null) + { + throw new ArgumentNullException(nameof(region)); + } + + foreach (var adapter in HostAdapters) + { + if (adapter.CanHandle(region)) + { + return adapter; + } + } + + var regionType = region.GetType(); + throw new ArgumentException( + $"Region host type '{regionType.FullName}' is not supported.", + nameof(region)); + } +} diff --git a/Common/RegionManager.cs b/Common/RegionManager.cs index 5ed5679..d0452f4 100644 --- a/Common/RegionManager.cs +++ b/Common/RegionManager.cs @@ -5,14 +5,9 @@ namespace SimpleNavigation.Common; public sealed class RegionManager : IRegionManager { - private static readonly IRegionHostAdapter[] HostAdapters = - { - new FrameRegionAdapter(), - new ContentControlRegionAdapter(), - }; - private readonly Dictionary> regions = new(StringComparer.Ordinal); + private readonly object syncRoot = new(); public void RegisterRegion(string regionName, FrameworkElement region) { @@ -23,20 +18,23 @@ public void RegisterRegion(string regionName, FrameworkElement region) throw new ArgumentNullException(nameof(region)); } - ResolveHostAdapter(region); + RegionHostAdapterResolver.GetRequired(region); - if (regions.TryGetValue(regionName, out var existingReference) && - existingReference.TryGetTarget(out var existingRegion)) + lock (syncRoot) { - if (ReferenceEquals(existingRegion, region)) + if (regions.TryGetValue(regionName, out var existingReference) && + existingReference.TryGetTarget(out var existingRegion)) { - return; + if (ReferenceEquals(existingRegion, region)) + { + return; + } + + throw new InvalidOperationException($"Region '{regionName}' is already registered."); } - throw new InvalidOperationException($"Region '{regionName}' is already registered."); + regions[regionName] = new WeakReference(region); } - - regions[regionName] = new WeakReference(region); } public bool UnregisterRegion(string regionName, FrameworkElement region) @@ -48,41 +46,47 @@ public bool UnregisterRegion(string regionName, FrameworkElement region) throw new ArgumentNullException(nameof(region)); } - if (!regions.TryGetValue(regionName, out var existingReference)) + lock (syncRoot) { - return false; - } + if (!regions.TryGetValue(regionName, out var existingReference)) + { + return false; + } - if (!existingReference.TryGetTarget(out var existingRegion)) - { - regions.Remove(regionName); - return false; - } + if (!existingReference.TryGetTarget(out var existingRegion)) + { + regions.Remove(regionName); + return false; + } - if (!ReferenceEquals(existingRegion, region)) - { - return false; - } + if (!ReferenceEquals(existingRegion, region)) + { + return false; + } - return regions.Remove(regionName); + return regions.Remove(regionName); + } } public FrameworkElement? GetRegion(string regionName) { ValidateRegionName(regionName); - if (!regions.TryGetValue(regionName, out var regionReference)) + lock (syncRoot) { - return null; - } + if (!regions.TryGetValue(regionName, out var regionReference)) + { + return null; + } - if (regionReference.TryGetTarget(out var region)) - { - return region; - } + if (regionReference.TryGetTarget(out var region)) + { + return region; + } - regions.Remove(regionName); - return null; + regions.Remove(regionName); + return null; + } } public TRegion? GetRegion(string regionName) where TRegion : FrameworkElement @@ -97,20 +101,4 @@ private static void ValidateRegionName(string regionName) throw new ArgumentException("Region name cannot be null, empty, or whitespace.", nameof(regionName)); } } - - private static IRegionHostAdapter ResolveHostAdapter(FrameworkElement region) - { - foreach (var adapter in HostAdapters) - { - if (adapter.CanHandle(region)) - { - return adapter; - } - } - - var regionType = region.GetType(); - throw new ArgumentException( - $"Region host type '{regionType.FullName}' is not supported.", - nameof(region)); - } } diff --git a/Tests/SimpleNavigation.Tests/RegionManagerTests.cs b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs index 2a0c471..f91cfab 100644 --- a/Tests/SimpleNavigation.Tests/RegionManagerTests.cs +++ b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Threading; using System.Windows; using System.Windows.Controls; using SimpleNavigation.Common; @@ -56,6 +57,23 @@ public void RegisterRegion_SameHostRepeated_IsIdempotent() }); } + [Fact] + public void RegisterRegion_NamesAreOrdinalAndCaseSensitive() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var upperCaseRegion = new ContentControl(); + var lowerCaseRegion = new ContentControl(); + + manager.RegisterRegion("Main", upperCaseRegion); + manager.RegisterRegion("main", lowerCaseRegion); + + Assert.Same(upperCaseRegion, manager.GetRegion("Main")); + Assert.Same(lowerCaseRegion, manager.GetRegion("main")); + }); + } + [Fact] public void RegisterRegion_DifferentLiveHostForSameName_Throws() { @@ -74,6 +92,98 @@ public void RegisterRegion_DifferentLiveHostForSameName_Throws() }); } + [Fact] + public void RegisterRegion_ConcurrentDifferentHosts_AllowsExactlyOneOwner() + { + StaTest.Run(() => + { + const int workerCount = 32; + const int roundCount = 20; + const string regionName = "Concurrent"; + + var hosts = new ContentControl[workerCount]; + for (var index = 0; index < hosts.Length; index++) + { + hosts[index] = new ContentControl(); + } + + var managers = new RegionManager[roundCount]; + for (var round = 0; round < managers.Length; round++) + { + managers[round] = new RegionManager(); + } + + var outcomes = new Exception?[roundCount, workerCount]; + var workers = new Thread[workerCount]; + using var barrier = new Barrier(workerCount + 1); + + for (var index = 0; index < workers.Length; index++) + { + var workerIndex = index; + workers[index] = new Thread(() => + { + for (var round = 0; round < roundCount; round++) + { + barrier.SignalAndWait(); + + try + { + managers[round].RegisterRegion(regionName, hosts[workerIndex]); + } + catch (Exception exception) + { + outcomes[round, workerIndex] = exception; + } + + barrier.SignalAndWait(); + } + }) + { + IsBackground = true, + }; + workers[index].Start(); + } + + for (var round = 0; round < roundCount; round++) + { + barrier.SignalAndWait(); + barrier.SignalAndWait(); + } + + foreach (var worker in workers) + { + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "A registration worker did not finish."); + } + + for (var round = 0; round < roundCount; round++) + { + var successCount = 0; + var successIndex = -1; + + for (var worker = 0; worker < workerCount; worker++) + { + var outcome = outcomes[round, worker]; + if (outcome == null) + { + successCount++; + successIndex = worker; + } + else + { + Assert.IsType(outcome); + } + } + + Assert.True( + successCount == 1, + $"Round {round} accepted {successCount} owners instead of exactly one."); + Assert.Same(hosts[successIndex], managers[round].GetRegion(regionName)); + } + + GC.KeepAlive(hosts); + }); + } + [Fact] public void UnregisterRegion_WrongHost_ReturnsFalseAndPreservesOwner() { @@ -118,6 +228,33 @@ public void RegisterRegion_InvalidName_Throws(string? regionName) }); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetRegion_InvalidName_Throws(string? regionName) + { + var manager = new RegionManager(); + + Assert.Throws(() => manager.GetRegion(regionName!)); + Assert.Throws(() => manager.GetRegion(regionName!)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UnregisterRegion_InvalidName_Throws(string? regionName) + { + StaTest.Run(() => + { + var manager = new RegionManager(); + + Assert.Throws( + () => manager.UnregisterRegion(regionName!, new ContentControl())); + }); + } + [Fact] public void RegisterRegion_NullHost_Throws() { @@ -129,6 +266,17 @@ public void RegisterRegion_NullHost_Throws() Assert.Equal("region", exception.ParamName); } + [Fact] + public void UnregisterRegion_NullHost_Throws() + { + var manager = new RegionManager(); + + var exception = Assert.Throws( + () => manager.UnregisterRegion("Main", null!)); + + Assert.Equal("region", exception.ParamName); + } + [Fact] public void RegionManager_DoesNotKeepAbandonedHostAlive() { @@ -137,13 +285,30 @@ public void RegionManager_DoesNotKeepAbandonedHostAlive() var manager = new RegionManager(); var weakRegion = RegisterAbandonedRegion(manager, "Main"); - ForceGarbageCollection(); + ForceGarbageCollection(weakRegion); Assert.False(weakRegion.IsAlive); Assert.Null(manager.GetRegion("Main")); }); } + [Fact] + public void RegisterRegion_CollectedOwner_CanBeReplacedWithoutLookup() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var weakRegion = RegisterAbandonedRegion(manager, "Main"); + ForceGarbageCollection(weakRegion); + Assert.False(weakRegion.IsAlive); + + var replacement = new ContentControl(); + manager.RegisterRegion("Main", replacement); + + Assert.Same(replacement, manager.GetRegion("Main")); + }); + } + [MethodImpl(MethodImplOptions.NoInlining)] private static WeakReference RegisterAbandonedRegion(RegionManager manager, string regionName) { @@ -152,10 +317,15 @@ private static WeakReference RegisterAbandonedRegion(RegionManager manager, stri return new WeakReference(region); } - private static void ForceGarbageCollection() + private static void ForceGarbageCollection(WeakReference weakReference) { - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); + const int attemptLimit = 10; + + for (var attempt = 0; attempt < attemptLimit && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } } } From a842a2e91b3e94fa9ccc6b6e55c8f9751f142afe Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 00:29:29 +0800 Subject: [PATCH 07/42] feat: add Region attached registration --- Common/RegionManager.cs | 209 ++++++++- Services/Region.cs | 463 ++++++++++++++++++++ Tests/SimpleNavigation.Tests/RegionTests.cs | 412 +++++++++++++++++ 3 files changed, 1067 insertions(+), 17 deletions(-) create mode 100644 Services/Region.cs create mode 100644 Tests/SimpleNavigation.Tests/RegionTests.cs diff --git a/Common/RegionManager.cs b/Common/RegionManager.cs index d0452f4..4f82a2b 100644 --- a/Common/RegionManager.cs +++ b/Common/RegionManager.cs @@ -1,13 +1,56 @@ using System.Windows; using SimpleNavigation.Interface; +using SimpleNavigation.Services; namespace SimpleNavigation.Common; -public sealed class RegionManager : IRegionManager +public sealed class RegionManager : IRegionManager, IDisposable { - private readonly Dictionary> regions = + private readonly Dictionary regions = new(StringComparer.Ordinal); + private readonly List pendingDeclarationChanges = new(); private readonly object syncRoot = new(); + private bool isImportingDeclarations = true; + private bool isDisposed; + + public RegionManager() + { + Region.Subscribe(OnDeclarationChanged); + + try + { + var snapshot = Region.GetActiveSnapshot(); + + lock (syncRoot) + { + foreach (var change in snapshot) + { + ApplyDeclarationChangeUnderLock(change); + } + + foreach (var change in pendingDeclarationChanges) + { + ApplyDeclarationChangeUnderLock(change); + } + + pendingDeclarationChanges.Clear(); + isImportingDeclarations = false; + } + } + catch + { + lock (syncRoot) + { + isDisposed = true; + isImportingDeclarations = false; + pendingDeclarationChanges.Clear(); + regions.Clear(); + } + + Region.Unsubscribe(OnDeclarationChanged); + throw; + } + } public void RegisterRegion(string regionName, FrameworkElement region) { @@ -22,18 +65,23 @@ public void RegisterRegion(string regionName, FrameworkElement region) lock (syncRoot) { - if (regions.TryGetValue(regionName, out var existingReference) && - existingReference.TryGetTarget(out var existingRegion)) + ThrowIfDisposedUnderLock(); + + if (TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) { if (ReferenceEquals(existingRegion, region)) { + existingEntry.HasProgrammaticOwnership = true; return; } throw new InvalidOperationException($"Region '{regionName}' is already registered."); } - regions[regionName] = new WeakReference(region); + regions[regionName] = new RegionEntry(region) + { + HasProgrammaticOwnership = true, + }; } } @@ -48,23 +96,26 @@ public bool UnregisterRegion(string regionName, FrameworkElement region) lock (syncRoot) { - if (!regions.TryGetValue(regionName, out var existingReference)) + ThrowIfDisposedUnderLock(); + + if (!TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) { return false; } - if (!existingReference.TryGetTarget(out var existingRegion)) + if (!ReferenceEquals(existingRegion, region) || + !existingEntry.HasProgrammaticOwnership) { - regions.Remove(regionName); return false; } - if (!ReferenceEquals(existingRegion, region)) + existingEntry.HasProgrammaticOwnership = false; + if (existingEntry.AttachedActivationTokens.Count == 0) { - return false; + regions.Remove(regionName); } - return regions.Remove(regionName); + return true; } } @@ -74,17 +125,13 @@ public bool UnregisterRegion(string regionName, FrameworkElement region) lock (syncRoot) { - if (!regions.TryGetValue(regionName, out var regionReference)) - { - return null; - } + ThrowIfDisposedUnderLock(); - if (regionReference.TryGetTarget(out var region)) + if (TryGetLiveEntryUnderLock(regionName, out _, out var region)) { return region; } - regions.Remove(regionName); return null; } } @@ -94,6 +141,120 @@ public bool UnregisterRegion(string regionName, FrameworkElement region) return GetRegion(regionName) as TRegion; } + public void Dispose() + { + lock (syncRoot) + { + if (isDisposed) + { + return; + } + + isDisposed = true; + isImportingDeclarations = false; + pendingDeclarationChanges.Clear(); + regions.Clear(); + } + + Region.Unsubscribe(OnDeclarationChanged); + } + + private void OnDeclarationChanged(RegionDeclarationChange change) + { + lock (syncRoot) + { + if (isDisposed) + { + return; + } + + if (isImportingDeclarations) + { + pendingDeclarationChanges.Add(change); + return; + } + + ApplyDeclarationChangeUnderLock(change); + } + } + + private void ApplyDeclarationChangeUnderLock(RegionDeclarationChange change) + { + if (change.Kind == RegionDeclarationChangeKind.Add) + { + AddAttachedOwnershipUnderLock(change); + return; + } + + RemoveAttachedOwnershipUnderLock(change); + } + + private void AddAttachedOwnershipUnderLock(RegionDeclarationChange change) + { + var host = change.Host; + + if (TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost)) + { + if (!ReferenceEquals(existingHost, host)) + { + throw new InvalidOperationException($"Region '{change.Name}' is already registered."); + } + + existingEntry.AttachedActivationTokens.Add(change.ActivationToken); + return; + } + + var entry = new RegionEntry(host); + entry.AttachedActivationTokens.Add(change.ActivationToken); + regions[change.Name] = entry; + } + + private void RemoveAttachedOwnershipUnderLock(RegionDeclarationChange change) + { + if (!TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost) || + !ReferenceEquals(existingHost, change.Host)) + { + return; + } + + existingEntry.AttachedActivationTokens.Remove(change.ActivationToken); + if (!existingEntry.HasProgrammaticOwnership && + existingEntry.AttachedActivationTokens.Count == 0) + { + regions.Remove(change.Name); + } + } + + private bool TryGetLiveEntryUnderLock( + string regionName, + out RegionEntry entry, + out FrameworkElement region) + { + if (!regions.TryGetValue(regionName, out entry!)) + { + region = null!; + return false; + } + + if (entry.Host.TryGetTarget(out region!)) + { + return true; + } + + regions.Remove(regionName); + entry = null!; + region = null!; + return false; + } + + private void ThrowIfDisposedUnderLock() + { + if (isDisposed) + { + throw new ObjectDisposedException(nameof(RegionManager)); + } + } + private static void ValidateRegionName(string regionName) { if (string.IsNullOrWhiteSpace(regionName)) @@ -101,4 +262,18 @@ private static void ValidateRegionName(string regionName) throw new ArgumentException("Region name cannot be null, empty, or whitespace.", nameof(regionName)); } } + + private sealed class RegionEntry + { + public RegionEntry(FrameworkElement host) + { + Host = new WeakReference(host); + } + + public WeakReference Host { get; } + + public bool HasProgrammaticOwnership { get; set; } + + public HashSet AttachedActivationTokens { get; } = new(); + } } diff --git a/Services/Region.cs b/Services/Region.cs new file mode 100644 index 0000000..c6f72f5 --- /dev/null +++ b/Services/Region.cs @@ -0,0 +1,463 @@ +using System.Runtime.ExceptionServices; +using System.Windows; +using SimpleNavigation.Common; + +namespace SimpleNavigation.Services; + +public static class Region +{ + private static readonly object SyncRoot = new(); + private static readonly List Declarations = new(); + private static readonly List> Subscribers = new(); + private static long nextActivationToken; + + public static readonly DependencyProperty RegionNameProperty = + DependencyProperty.RegisterAttached( + "RegionName", + typeof(string), + typeof(Region), + new PropertyMetadata(null, OnRegionNameChanged)); + + public static string? GetRegionName(DependencyObject obj) + { + if (obj == null) + { + throw new ArgumentNullException(nameof(obj)); + } + + return (string?)obj.GetValue(RegionNameProperty); + } + + public static void SetRegionName(DependencyObject obj, string value) + { + if (obj == null) + { + throw new ArgumentNullException(nameof(obj)); + } + + ValidateRegionName(value); + var host = GetRequiredFrameworkElement(obj); + RegionHostAdapterResolver.GetRequired(host); + ValidateAttachedNameAvailability(host, value); + + obj.SetValue(RegionNameProperty, value); + } + + internal static void Subscribe(Action subscriber) + { + if (subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + lock (SyncRoot) + { + Subscribers.Add(subscriber); + } + } + + internal static void Unsubscribe(Action subscriber) + { + if (subscriber == null) + { + return; + } + + lock (SyncRoot) + { + Subscribers.Remove(subscriber); + } + } + + internal static IReadOnlyList GetActiveSnapshot() + { + lock (SyncRoot) + { + PruneDeadDeclarationsUnderLock(); + + var snapshot = new List(); + foreach (var declaration in Declarations) + { + if (declaration.IsActive && declaration.Host.TryGetTarget(out var host)) + { + snapshot.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Add)); + } + } + + return snapshot; + } + } + + private static void OnRegionNameChanged( + DependencyObject dependencyObject, + DependencyPropertyChangedEventArgs eventArgs) + { + if (eventArgs.NewValue == null) + { + ClearDeclaration(dependencyObject); + return; + } + + var regionName = (string)eventArgs.NewValue; + ValidateRegionName(regionName); + var host = GetRequiredFrameworkElement(dependencyObject); + RegionHostAdapterResolver.GetRequired(host); + + ApplyRegionName(host, regionName); + } + + private static void ApplyRegionName(FrameworkElement host, string regionName) + { + Declaration? createdDeclaration = null; + List changes; + Action[] subscribers; + + lock (SyncRoot) + { + PruneDeadDeclarationsUnderLock(); + ValidateAttachedNameAvailabilityUnderLock(host, regionName); + + var declaration = FindDeclarationUnderLock(host); + if (declaration != null && + string.Equals(declaration.Name, regionName, StringComparison.Ordinal)) + { + return; + } + + changes = new List(2); + + if (declaration == null) + { + declaration = new Declaration( + new WeakReference(host), + regionName, + isActive: true, + GetNextActivationTokenUnderLock()); + Declarations.Add(declaration); + createdDeclaration = declaration; + } + else + { + if (declaration.IsActive) + { + changes.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Remove)); + } + + declaration.Name = regionName; + declaration.IsActive = true; + declaration.ActivationToken = GetNextActivationTokenUnderLock(); + } + + changes.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Add)); + subscribers = Subscribers.ToArray(); + } + + if (createdDeclaration != null) + { + try + { + AttachLifecycleHandlers(host); + } + catch + { + DetachLifecycleHandlers(host); + + lock (SyncRoot) + { + Declarations.Remove(createdDeclaration); + } + + throw; + } + } + + PublishChanges(changes, subscribers); + } + + private static void ClearDeclaration(DependencyObject dependencyObject) + { + if (dependencyObject is not FrameworkElement host) + { + return; + } + + Declaration? removedDeclaration; + List changes; + Action[] subscribers; + + lock (SyncRoot) + { + PruneDeadDeclarationsUnderLock(); + removedDeclaration = FindDeclarationUnderLock(host); + if (removedDeclaration == null) + { + return; + } + + changes = new List(1); + if (removedDeclaration.IsActive) + { + changes.Add(CreateChange( + removedDeclaration, + host, + RegionDeclarationChangeKind.Remove)); + } + + Declarations.Remove(removedDeclaration); + subscribers = Subscribers.ToArray(); + } + + DetachLifecycleHandlers(host); + PublishChanges(changes, subscribers); + } + + private static void OnHostLoaded(object sender, RoutedEventArgs eventArgs) + { + if (sender is not FrameworkElement host) + { + return; + } + + RegionDeclarationChange? change = null; + Action[] subscribers = Array.Empty>(); + + lock (SyncRoot) + { + PruneDeadDeclarationsUnderLock(); + var declaration = FindDeclarationUnderLock(host); + if (declaration == null || declaration.IsActive) + { + return; + } + + declaration.IsActive = true; + declaration.ActivationToken = GetNextActivationTokenUnderLock(); + change = CreateChange(declaration, host, RegionDeclarationChangeKind.Add); + subscribers = Subscribers.ToArray(); + } + + PublishChanges(new[] { change }, subscribers); + } + + private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) + { + if (sender is not FrameworkElement host) + { + return; + } + + RegionDeclarationChange? change = null; + Action[] subscribers = Array.Empty>(); + + lock (SyncRoot) + { + PruneDeadDeclarationsUnderLock(); + var declaration = FindDeclarationUnderLock(host); + if (declaration == null || !declaration.IsActive) + { + return; + } + + declaration.IsActive = false; + change = CreateChange(declaration, host, RegionDeclarationChangeKind.Remove); + subscribers = Subscribers.ToArray(); + } + + PublishChanges(new[] { change }, subscribers); + } + + private static void AttachLifecycleHandlers(FrameworkElement host) + { + host.Loaded += OnHostLoaded; + host.Unloaded += OnHostUnloaded; + } + + private static void DetachLifecycleHandlers(FrameworkElement host) + { + host.Loaded -= OnHostLoaded; + host.Unloaded -= OnHostUnloaded; + } + + private static void ValidateAttachedNameAvailability( + FrameworkElement host, + string regionName) + { + lock (SyncRoot) + { + PruneDeadDeclarationsUnderLock(); + ValidateAttachedNameAvailabilityUnderLock(host, regionName); + } + } + + private static void ValidateAttachedNameAvailabilityUnderLock( + FrameworkElement host, + string regionName) + { + foreach (var declaration in Declarations) + { + if (!string.Equals(declaration.Name, regionName, StringComparison.Ordinal) || + !declaration.Host.TryGetTarget(out var existingHost) || + ReferenceEquals(existingHost, host)) + { + continue; + } + + throw new InvalidOperationException( + $"Region '{regionName}' is already declared on another host."); + } + } + + private static FrameworkElement GetRequiredFrameworkElement(DependencyObject obj) + { + if (obj is FrameworkElement host) + { + return host; + } + + throw new ArgumentException( + $"Region host type '{obj.GetType().FullName}' is not a FrameworkElement.", + nameof(obj)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException( + "Region name cannot be null, empty, or whitespace.", + nameof(regionName)); + } + } + + private static Declaration? FindDeclarationUnderLock(FrameworkElement host) + { + foreach (var declaration in Declarations) + { + if (declaration.Host.TryGetTarget(out var existingHost) && + ReferenceEquals(existingHost, host)) + { + return declaration; + } + } + + return null; + } + + private static void PruneDeadDeclarationsUnderLock() + { + for (var index = Declarations.Count - 1; index >= 0; index--) + { + if (!Declarations[index].Host.TryGetTarget(out _)) + { + Declarations.RemoveAt(index); + } + } + } + + private static long GetNextActivationTokenUnderLock() + { + return ++nextActivationToken; + } + + private static RegionDeclarationChange CreateChange( + Declaration declaration, + FrameworkElement host, + RegionDeclarationChangeKind kind) + { + return new RegionDeclarationChange( + declaration.Name, + declaration.Host, + host, + declaration.ActivationToken, + kind); + } + + private static void PublishChanges( + IEnumerable changes, + IReadOnlyList> subscribers) + { + ExceptionDispatchInfo? firstFailure = null; + + foreach (var change in changes) + { + foreach (var subscriber in subscribers) + { + try + { + subscriber(change); + } + catch (Exception exception) + { + firstFailure ??= ExceptionDispatchInfo.Capture(exception); + } + } + } + + firstFailure?.Throw(); + } + + private sealed class Declaration + { + public Declaration( + WeakReference host, + string name, + bool isActive, + long activationToken) + { + Host = host; + Name = name; + IsActive = isActive; + ActivationToken = activationToken; + } + + public WeakReference Host { get; } + + public string Name { get; set; } + + public bool IsActive { get; set; } + + public long ActivationToken { get; set; } + } +} + +internal enum RegionDeclarationChangeKind +{ + Add, + Remove, +} + +internal sealed class RegionDeclarationChange +{ + public RegionDeclarationChange( + string name, + WeakReference hostReference, + FrameworkElement host, + long activationToken, + RegionDeclarationChangeKind kind) + { + Name = name; + HostReference = hostReference; + Host = host; + ActivationToken = activationToken; + Kind = kind; + } + + public string Name { get; } + + public WeakReference HostReference { get; } + + public FrameworkElement Host { get; } + + public long ActivationToken { get; } + + public RegionDeclarationChangeKind Kind { get; } +} diff --git a/Tests/SimpleNavigation.Tests/RegionTests.cs b/Tests/SimpleNavigation.Tests/RegionTests.cs new file mode 100644 index 0000000..5a6f801 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/RegionTests.cs @@ -0,0 +1,412 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using SimpleNavigation.Common; +using SimpleNavigation.Services; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class RegionTests +{ + [Fact] + public void SetRegionName_BeforeManagerCreation_ReplaysActiveFrame() + { + StaTest.Run(() => + { + var host = new Frame(); + + try + { + Region.SetRegionName(host, "Pages"); + + using var manager = new RegionManager(); + + Assert.Same(host, manager.GetRegion("Pages")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_ContentControl_RegistersWithManager() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + + Region.SetRegionName(host, "Main"); + + Assert.Same(host, manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_UnsupportedFrameworkElement_ThrowsWithHostType() + { + StaTest.Run(() => + { + var host = new Grid(); + + var exception = Assert.Throws( + () => Region.SetRegionName(host, "Main")); + + Assert.Contains(typeof(Grid).FullName!, exception.Message); + }); + } + + [Fact] + public void SetRegionName_NonFrameworkElement_ThrowsWithHostType() + { + StaTest.Run(() => + { + var host = new DependencyObject(); + + var exception = Assert.Throws( + () => Region.SetRegionName(host, "NonElement")); + + Assert.Contains(typeof(DependencyObject).FullName!, exception.Message); + }); + } + + [Fact] + public void SetRegionName_Rename_RemovesOldNameAndAddsNewName() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "Old"); + + Region.SetRegionName(host, "New"); + + Assert.Null(manager.GetRegion("Old")); + Assert.Same(host, manager.GetRegion("New")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_RejectedRename_PreservesOldDeclaration() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(first, "Preserved"); + Region.SetRegionName(second, "Occupied"); + + Assert.Throws( + () => Region.SetRegionName(first, "Occupied")); + + Assert.Equal("Preserved", Region.GetRegionName(first)); + Assert.Same(first, manager.GetRegion("Preserved")); + Assert.Same(second, manager.GetRegion("Occupied")); + } + finally + { + ClearRegionName(first); + ClearRegionName(second); + } + }); + } + + [Fact] + public void ClearValue_ActiveDeclaration_RemovesRegistration() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "Main"); + + host.ClearValue(Region.RegionNameProperty); + + Assert.Null(Region.GetRegionName(host)); + Assert.Null(manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetRegionName_InvalidName_Throws(string? regionName) + { + StaTest.Run(() => + { + var host = new ContentControl(); + + Assert.Throws( + () => Region.SetRegionName(host, regionName!)); + }); + } + + [Fact] + public void UnloadedAndLoaded_AttachedOnlyDeclaration_RemovesThenRestoresHost() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "Main"); + + RaiseLifecycleEvent(host, FrameworkElement.UnloadedEvent); + Assert.Null(manager.GetRegion("Main")); + + RaiseLifecycleEvent(host, FrameworkElement.LoadedEvent); + Assert.Same(host, manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void ClearValue_WhileUnloaded_PreventsLaterReactivation() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "UnloadedClear"); + RaiseLifecycleEvent(host, FrameworkElement.UnloadedEvent); + + host.ClearValue(Region.RegionNameProperty); + RaiseLifecycleEvent(host, FrameworkElement.LoadedEvent); + + Assert.Null(manager.GetRegion("UnloadedClear")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_DuplicateAttachedNameOnLiveHosts_ThrowsWithoutManager() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + + try + { + Region.SetRegionName(first, "Main"); + + var exception = Assert.Throws( + () => Region.SetRegionName(second, "Main")); + + Assert.Contains("Main", exception.Message); + } + finally + { + ClearRegionName(first); + ClearRegionName(second); + } + }); + } + + [Fact] + public void ProgrammaticAndAttachedOwnership_AreRemovedIndependently() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + manager.RegisterRegion("Main", host); + Region.SetRegionName(host, "Main"); + + RaiseLifecycleEvent(host, FrameworkElement.UnloadedEvent); + Assert.Same(host, manager.GetRegion("Main")); + + Assert.True(manager.UnregisterRegion("Main", host)); + Assert.Null(manager.GetRegion("Main")); + + RaiseLifecycleEvent(host, FrameworkElement.LoadedEvent); + Assert.Same(host, manager.GetRegion("Main")); + + host.ClearValue(Region.RegionNameProperty); + Assert.Null(manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void UnregisterRegion_AttachedOnlyOwnership_ReturnsFalseAndPreservesHost() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "AttachedOnly"); + + Assert.False(manager.UnregisterRegion("AttachedOnly", host)); + Assert.Same(host, manager.GetRegion("AttachedOnly")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void DeclarationFailure_InOneManager_StillPublishesToOtherManagers() + { + StaTest.Run(() => + { + var blocker = new ContentControl(); + var declaredHost = new ContentControl(); + + try + { + using var throwingManager = new RegionManager(); + using var receivingManager = new RegionManager(); + throwingManager.RegisterRegion("Fanout", blocker); + + var exception = Assert.Throws( + () => Region.SetRegionName(declaredHost, "Fanout")); + + Assert.Contains("Fanout", exception.Message); + Assert.Same(blocker, throwingManager.GetRegion("Fanout")); + Assert.Same(declaredHost, receivingManager.GetRegion("Fanout")); + } + finally + { + ClearRegionName(declaredHost); + } + }); + } + + [Fact] + public void DeclarationCatalog_DoesNotKeepAbandonedHostAlive() + { + StaTest.Run(() => + { + var weakHost = DeclareAbandonedRegion("Abandoned"); + + ForceGarbageCollection(weakHost); + + Assert.False(weakHost.IsAlive); + using var manager = new RegionManager(); + Assert.Null(manager.GetRegion("Abandoned")); + }); + } + + [Fact] + public void ManagersCreatedAroundDeclaration_ReceiveItAndDisposeUnsubscribesOne() + { + StaTest.Run(() => + { + var firstHost = new ContentControl(); + var secondHost = new Frame(); + var earlyManager = new RegionManager(); + + try + { + Region.SetRegionName(firstHost, "Main"); + using var lateManager = new RegionManager(); + + Assert.Same(firstHost, earlyManager.GetRegion("Main")); + Assert.Same(firstHost, lateManager.GetRegion("Main")); + + earlyManager.Dispose(); + Region.SetRegionName(secondHost, "Pages"); + + Assert.Same(secondHost, lateManager.GetRegion("Pages")); + Assert.Throws(() => earlyManager.GetRegion("Pages")); + } + finally + { + earlyManager.Dispose(); + ClearRegionName(firstHost); + ClearRegionName(secondHost); + } + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference DeclareAbandonedRegion(string regionName) + { + var host = new ContentControl(); + Region.SetRegionName(host, regionName); + return new WeakReference(host); + } + + private static void ClearRegionName(FrameworkElement host) + { + if (host.ReadLocalValue(Region.RegionNameProperty) != DependencyProperty.UnsetValue) + { + host.ClearValue(Region.RegionNameProperty); + } + } + + private static void RaiseLifecycleEvent(FrameworkElement host, RoutedEvent routedEvent) + { + host.RaiseEvent(new RoutedEventArgs(routedEvent, host)); + } + + private static void ForceGarbageCollection(WeakReference weakReference) + { + const int attemptLimit = 10; + + for (var attempt = 0; attempt < attemptLimit && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } +} From f7ba46b9276fbf1c0c4e86affa8fb3b41bd5331c Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 00:51:41 +0800 Subject: [PATCH 08/42] fix: harden Region lifecycle --- Common/RegionManager.cs | 8 +- Services/Region.cs | 153 +++++++- Tests/SimpleNavigation.Tests/RegionTests.cs | 390 ++++++++++++++++++++ 3 files changed, 532 insertions(+), 19 deletions(-) diff --git a/Common/RegionManager.cs b/Common/RegionManager.cs index 4f82a2b..1a91024 100644 --- a/Common/RegionManager.cs +++ b/Common/RegionManager.cs @@ -8,6 +8,7 @@ public sealed class RegionManager : IRegionManager, IDisposable { private readonly Dictionary regions = new(StringComparer.Ordinal); + private readonly Action declarationSubscriber; private readonly List pendingDeclarationChanges = new(); private readonly object syncRoot = new(); private bool isImportingDeclarations = true; @@ -15,7 +16,8 @@ public sealed class RegionManager : IRegionManager, IDisposable public RegionManager() { - Region.Subscribe(OnDeclarationChanged); + declarationSubscriber = OnDeclarationChanged; + Region.Subscribe(declarationSubscriber); try { @@ -47,7 +49,7 @@ public RegionManager() regions.Clear(); } - Region.Unsubscribe(OnDeclarationChanged); + Region.Unsubscribe(declarationSubscriber); throw; } } @@ -156,7 +158,7 @@ public void Dispose() regions.Clear(); } - Region.Unsubscribe(OnDeclarationChanged); + Region.Unsubscribe(declarationSubscriber); } private void OnDeclarationChanged(RegionDeclarationChange change) diff --git a/Services/Region.cs b/Services/Region.cs index c6f72f5..3cfe8ff 100644 --- a/Services/Region.cs +++ b/Services/Region.cs @@ -7,8 +7,9 @@ namespace SimpleNavigation.Services; public static class Region { private static readonly object SyncRoot = new(); + private static readonly object PublicationGate = new(); private static readonly List Declarations = new(); - private static readonly List> Subscribers = new(); + private static readonly List>> Subscribers = new(); private static long nextActivationToken; public static readonly DependencyProperty RegionNameProperty = @@ -52,7 +53,8 @@ internal static void Subscribe(Action subscriber) lock (SyncRoot) { - Subscribers.Add(subscriber); + PruneDeadSubscribersUnderLock(); + Subscribers.Add(new WeakReference>(subscriber)); } } @@ -65,7 +67,14 @@ internal static void Unsubscribe(Action subscriber) lock (SyncRoot) { - Subscribers.Remove(subscriber); + for (var index = Subscribers.Count - 1; index >= 0; index--) + { + if (!Subscribers[index].TryGetTarget(out var existingSubscriber) || + existingSubscriber.Equals(subscriber)) + { + Subscribers.RemoveAt(index); + } + } } } @@ -102,14 +111,46 @@ private static void OnRegionNameChanged( } var regionName = (string)eventArgs.NewValue; - ValidateRegionName(regionName); - var host = GetRequiredFrameworkElement(dependencyObject); - RegionHostAdapterResolver.GetRequired(host); - ApplyRegionName(host, regionName); + try + { + ValidateRegionName(regionName); + var host = GetRequiredFrameworkElement(dependencyObject); + RegionHostAdapterResolver.GetRequired(host); + ApplyRegionName(host, regionName); + } + catch (Exception exception) + { + var originalFailure = ExceptionDispatchInfo.Capture(exception); + + if (!HasMatchingDeclaration(dependencyObject, regionName)) + { + try + { + RestoreDependencyPropertyValue(dependencyObject, eventArgs.OldValue); + } + catch + { + // Preserve the validation or registration failure that caused the rollback. + } + } + + originalFailure.Throw(); + throw; + } } private static void ApplyRegionName(FrameworkElement host, string regionName) + { + lock (PublicationGate) + { + ApplyRegionNameUnderPublicationGate(host, regionName); + } + } + + private static void ApplyRegionNameUnderPublicationGate( + FrameworkElement host, + string regionName) { Declaration? createdDeclaration = null; List changes; @@ -158,7 +199,7 @@ private static void ApplyRegionName(FrameworkElement host, string regionName) declaration, host, RegionDeclarationChangeKind.Add)); - subscribers = Subscribers.ToArray(); + subscribers = GetLiveSubscribersUnderLock(); } if (createdDeclaration != null) @@ -190,6 +231,14 @@ private static void ClearDeclaration(DependencyObject dependencyObject) return; } + lock (PublicationGate) + { + ClearDeclarationUnderPublicationGate(host); + } + } + + private static void ClearDeclarationUnderPublicationGate(FrameworkElement host) + { Declaration? removedDeclaration; List changes; Action[] subscribers; @@ -213,7 +262,7 @@ private static void ClearDeclaration(DependencyObject dependencyObject) } Declarations.Remove(removedDeclaration); - subscribers = Subscribers.ToArray(); + subscribers = GetLiveSubscribersUnderLock(); } DetachLifecycleHandlers(host); @@ -227,6 +276,14 @@ private static void OnHostLoaded(object sender, RoutedEventArgs eventArgs) return; } + lock (PublicationGate) + { + ActivateHostUnderPublicationGate(host); + } + } + + private static void ActivateHostUnderPublicationGate(FrameworkElement host) + { RegionDeclarationChange? change = null; Action[] subscribers = Array.Empty>(); @@ -242,7 +299,7 @@ private static void OnHostLoaded(object sender, RoutedEventArgs eventArgs) declaration.IsActive = true; declaration.ActivationToken = GetNextActivationTokenUnderLock(); change = CreateChange(declaration, host, RegionDeclarationChangeKind.Add); - subscribers = Subscribers.ToArray(); + subscribers = GetLiveSubscribersUnderLock(); } PublishChanges(new[] { change }, subscribers); @@ -255,6 +312,14 @@ private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) return; } + lock (PublicationGate) + { + DeactivateHostUnderPublicationGate(host); + } + } + + private static void DeactivateHostUnderPublicationGate(FrameworkElement host) + { RegionDeclarationChange? change = null; Action[] subscribers = Array.Empty>(); @@ -269,7 +334,7 @@ private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) declaration.IsActive = false; change = CreateChange(declaration, host, RegionDeclarationChangeKind.Remove); - subscribers = Subscribers.ToArray(); + subscribers = GetLiveSubscribersUnderLock(); } PublishChanges(new[] { change }, subscribers); @@ -298,6 +363,36 @@ private static void ValidateAttachedNameAvailability( } } + private static bool HasMatchingDeclaration( + DependencyObject dependencyObject, + string regionName) + { + if (dependencyObject is not FrameworkElement host) + { + return false; + } + + lock (SyncRoot) + { + var declaration = FindDeclarationUnderLock(host); + return declaration != null && + string.Equals(declaration.Name, regionName, StringComparison.Ordinal); + } + } + + private static void RestoreDependencyPropertyValue( + DependencyObject dependencyObject, + object? oldValue) + { + if (oldValue == null || ReferenceEquals(oldValue, DependencyProperty.UnsetValue)) + { + dependencyObject.ClearValue(RegionNameProperty); + return; + } + + dependencyObject.SetValue(RegionNameProperty, oldValue); + } + private static void ValidateAttachedNameAvailabilityUnderLock( FrameworkElement host, string regionName) @@ -363,6 +458,37 @@ private static void PruneDeadDeclarationsUnderLock() } } + private static void PruneDeadSubscribersUnderLock() + { + for (var index = Subscribers.Count - 1; index >= 0; index--) + { + if (!Subscribers[index].TryGetTarget(out _)) + { + Subscribers.RemoveAt(index); + } + } + } + + private static Action[] GetLiveSubscribersUnderLock() + { + var liveSubscribers = new List>(Subscribers.Count); + + for (var index = 0; index < Subscribers.Count;) + { + if (Subscribers[index].TryGetTarget(out var subscriber)) + { + liveSubscribers.Add(subscriber); + index++; + } + else + { + Subscribers.RemoveAt(index); + } + } + + return liveSubscribers.ToArray(); + } + private static long GetNextActivationTokenUnderLock() { return ++nextActivationToken; @@ -375,7 +501,6 @@ private static RegionDeclarationChange CreateChange( { return new RegionDeclarationChange( declaration.Name, - declaration.Host, host, declaration.ActivationToken, kind); @@ -439,13 +564,11 @@ internal sealed class RegionDeclarationChange { public RegionDeclarationChange( string name, - WeakReference hostReference, FrameworkElement host, long activationToken, RegionDeclarationChangeKind kind) { Name = name; - HostReference = hostReference; Host = host; ActivationToken = activationToken; Kind = kind; @@ -453,8 +576,6 @@ public RegionDeclarationChange( public string Name { get; } - public WeakReference HostReference { get; } - public FrameworkElement Host { get; } public long ActivationToken { get; } diff --git a/Tests/SimpleNavigation.Tests/RegionTests.cs b/Tests/SimpleNavigation.Tests/RegionTests.cs index 5a6f801..fbe6b82 100644 --- a/Tests/SimpleNavigation.Tests/RegionTests.cs +++ b/Tests/SimpleNavigation.Tests/RegionTests.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Reflection; using System.Windows; using System.Windows.Controls; using SimpleNavigation.Common; @@ -248,6 +249,297 @@ public void SetRegionName_DuplicateAttachedNameOnLiveHosts_ThrowsWithoutManager( }); } + [Fact] + public void DirectSetValue_DuplicateName_RollsBackLoserDependencyProperty() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + + try + { + using var manager = new RegionManager(); + first.SetValue(Region.RegionNameProperty, "DirectTaken"); + + var exception = Assert.Throws( + () => second.SetValue(Region.RegionNameProperty, "DirectTaken")); + + Assert.Contains("DirectTaken", exception.Message); + Assert.Null(Region.GetRegionName(second)); + Assert.Same(first, manager.GetRegion("DirectTaken")); + } + finally + { + ClearRegionName(first); + ClearRegionName(second); + } + }); + } + + [Fact] + public void DirectSetValue_RejectedRename_RestoresOldValueAndOwnership() + { + StaTest.Run(() => + { + var renamedHost = new ContentControl(); + var occupiedHost = new ContentControl(); + + try + { + using var manager = new RegionManager(); + renamedHost.SetValue(Region.RegionNameProperty, "DirectOld"); + occupiedHost.SetValue(Region.RegionNameProperty, "DirectOccupied"); + + Assert.Throws( + () => renamedHost.SetValue(Region.RegionNameProperty, "DirectOccupied")); + + Assert.Equal("DirectOld", Region.GetRegionName(renamedHost)); + Assert.Same(renamedHost, manager.GetRegion("DirectOld")); + Assert.Same(occupiedHost, manager.GetRegion("DirectOccupied")); + } + finally + { + ClearRegionName(renamedHost); + ClearRegionName(occupiedHost); + } + }); + } + + [Fact] + public void DirectSetValue_ConcurrentSameName_OneWinsAndLoserRollsBack() + { + StaTest.Run(() => + { + const string regionName = "DirectRace"; + var hosts = new ContentControl?[2]; + var outcomes = new Exception?[2]; + var effectiveNames = new string?[2]; + var unexpectedFailures = new Exception?[2]; + var workers = new Thread[2]; + using var hostsCreated = new CountdownEvent(2); + using var start = new ManualResetEventSlim(false); + using var setCompleted = new CountdownEvent(2); + using var releaseCleanup = new ManualResetEventSlim(false); + + for (var index = 0; index < workers.Length; index++) + { + var workerIndex = index; + workers[index] = new Thread(() => + { + ContentControl? host = null; + + try + { + host = new ContentControl(); + hosts[workerIndex] = host; + hostsCreated.Signal(); + start.Wait(); + + try + { + host.SetValue(Region.RegionNameProperty, regionName); + } + catch (Exception exception) + { + outcomes[workerIndex] = exception; + } + + effectiveNames[workerIndex] = Region.GetRegionName(host); + } + catch (Exception exception) + { + unexpectedFailures[workerIndex] = exception; + } + finally + { + setCompleted.Signal(); + releaseCleanup.Wait(TimeSpan.FromSeconds(5)); + + if (host != null) + { + ClearRegionName(host); + } + } + }) + { + IsBackground = true, + }; + workers[index].SetApartmentState(ApartmentState.STA); + workers[index].Start(); + } + + RegionManager? manager = null; + + try + { + Assert.True(hostsCreated.Wait(TimeSpan.FromSeconds(5)), "Region hosts were not created."); + start.Set(); + Assert.True(setCompleted.Wait(TimeSpan.FromSeconds(5)), "Region setters did not finish."); + Assert.All(unexpectedFailures, failure => Assert.Null(failure)); + + var winnerIndex = Array.FindIndex(outcomes, outcome => outcome == null); + var loserIndex = Array.FindIndex(outcomes, outcome => outcome != null); + + Assert.InRange(winnerIndex, 0, 1); + Assert.InRange(loserIndex, 0, 1); + Assert.NotEqual(winnerIndex, loserIndex); + Assert.IsType(outcomes[loserIndex]); + Assert.Equal(regionName, effectiveNames[winnerIndex]); + Assert.Null(effectiveNames[loserIndex]); + + manager = new RegionManager(); + Assert.Same(hosts[winnerIndex], manager.GetRegion(regionName)); + } + finally + { + manager?.Dispose(); + releaseCleanup.Set(); + + foreach (var worker in workers) + { + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "A region setter worker did not finish."); + } + } + }); + } + + [Fact] + public void DeclarationPublication_PreservesCatalogMutationOrderAcrossDispatchers() + { + StaTest.Run(() => + { + const string regionName = "PublicationOrder"; + using var blocker = new PublicationOrderBlocker(regionName); + var subscriber = SubscribePublicationBlocker(blocker); + var firstHostReady = new ManualResetEventSlim(false); + var secondHostReady = new ManualResetEventSlim(false); + var clearFirst = new ManualResetEventSlim(false); + var setSecond = new ManualResetEventSlim(false); + var secondSetStarted = new ManualResetEventSlim(false); + var firstCompleted = new ManualResetEventSlim(false); + var secondCompleted = new ManualResetEventSlim(false); + var releaseSecondCleanup = new ManualResetEventSlim(false); + ContentControl? firstHost = null; + ContentControl? secondHost = null; + Exception? firstFailure = null; + Exception? secondFailure = null; + + var firstWorker = new Thread(() => + { + try + { + firstHost = new ContentControl(); + firstHost.SetValue(Region.RegionNameProperty, regionName); + firstHostReady.Set(); + clearFirst.Wait(); + firstHost.ClearValue(Region.RegionNameProperty); + } + catch (Exception exception) + { + firstFailure = exception; + } + finally + { + firstHostReady.Set(); + firstCompleted.Set(); + } + }) + { + IsBackground = true, + }; + firstWorker.SetApartmentState(ApartmentState.STA); + + var secondWorker = new Thread(() => + { + try + { + secondHost = new ContentControl(); + secondHostReady.Set(); + setSecond.Wait(); + secondSetStarted.Set(); + secondHost.SetValue(Region.RegionNameProperty, regionName); + } + catch (Exception exception) + { + secondFailure = exception; + } + finally + { + secondHostReady.Set(); + secondSetStarted.Set(); + secondCompleted.Set(); + releaseSecondCleanup.Wait(TimeSpan.FromSeconds(10)); + + if (secondHost != null) + { + ClearRegionName(secondHost); + } + } + }) + { + IsBackground = true, + }; + secondWorker.SetApartmentState(ApartmentState.STA); + + RegionManager? manager = null; + RegionManager? lateManager = null; + firstWorker.Start(); + secondWorker.Start(); + + try + { + Assert.True(firstHostReady.Wait(TimeSpan.FromSeconds(5)), "The first host was not declared."); + Assert.True(secondHostReady.Wait(TimeSpan.FromSeconds(5)), "The second host was not created."); + Assert.Null(firstFailure); + manager = new RegionManager(); + Assert.Same(firstHost, manager.GetRegion(regionName)); + + blocker.IsArmed = true; + clearFirst.Set(); + Assert.True(blocker.RemoveObserved.Wait(TimeSpan.FromSeconds(5)), "Remove publication was not blocked."); + + setSecond.Set(); + Assert.True(secondSetStarted.Wait(TimeSpan.FromSeconds(5)), "The replacement setter did not start."); + + if (blocker.ReplacementAddObserved.Wait(TimeSpan.FromMilliseconds(500))) + { + Assert.True(secondCompleted.Wait(TimeSpan.FromSeconds(5)), "The overtaking add did not finish."); + } + + blocker.ReleaseRemove.Set(); + Assert.True(firstCompleted.Wait(TimeSpan.FromSeconds(5)), "The first clear did not finish."); + Assert.True(secondCompleted.Wait(TimeSpan.FromSeconds(5)), "The replacement setter did not finish."); + + Assert.Null(firstFailure); + Assert.Null(secondFailure); + Assert.Same(secondHost, manager.GetRegion(regionName)); + lateManager = new RegionManager(); + Assert.Same(secondHost, lateManager.GetRegion(regionName)); + } + finally + { + blocker.ReleaseRemove.Set(); + clearFirst.Set(); + setSecond.Set(); + releaseSecondCleanup.Set(); + manager?.Dispose(); + lateManager?.Dispose(); + Assert.True(firstWorker.Join(TimeSpan.FromSeconds(5)), "The first publication worker did not finish."); + Assert.True(secondWorker.Join(TimeSpan.FromSeconds(5)), "The second publication worker did not finish."); + UnsubscribePublicationBlocker(subscriber); + firstHostReady.Dispose(); + secondHostReady.Dispose(); + clearFirst.Dispose(); + setSecond.Dispose(); + secondSetStarted.Dispose(); + firstCompleted.Dispose(); + secondCompleted.Dispose(); + releaseSecondCleanup.Dispose(); + } + }); + } + [Fact] public void ProgrammaticAndAttachedOwnership_AreRemovedIndependently() { @@ -345,6 +637,19 @@ public void DeclarationCatalog_DoesNotKeepAbandonedHostAlive() }); } + [Fact] + public void RegionSubscribers_DoNotKeepUndisposedManagerAlive() + { + StaTest.Run(() => + { + var weakManager = CreateAbandonedManager(); + + ForceGarbageCollection(weakManager); + + Assert.False(weakManager.IsAlive); + }); + } + [Fact] public void ManagersCreatedAroundDeclaration_ReceiveItAndDisposeUnsubscribesOne() { @@ -385,6 +690,34 @@ private static WeakReference DeclareAbandonedRegion(string regionName) return new WeakReference(host); } + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAbandonedManager() + { + var manager = new RegionManager(); + return new WeakReference(manager); + } + + private static Delegate SubscribePublicationBlocker(PublicationOrderBlocker blocker) + { + var changeType = typeof(Region).Assembly.GetType( + "SimpleNavigation.Services.RegionDeclarationChange", + throwOnError: true)!; + var actionType = typeof(Action<>).MakeGenericType(changeType); + var handler = typeof(PublicationOrderBlocker) + .GetMethod(nameof(PublicationOrderBlocker.Handle), BindingFlags.Instance | BindingFlags.Public)! + .MakeGenericMethod(changeType); + var subscriber = Delegate.CreateDelegate(actionType, blocker, handler); + var subscribe = typeof(Region).GetMethod("Subscribe", BindingFlags.Static | BindingFlags.NonPublic)!; + subscribe.Invoke(null, new object[] { subscriber }); + return subscriber; + } + + private static void UnsubscribePublicationBlocker(Delegate subscriber) + { + var unsubscribe = typeof(Region).GetMethod("Unsubscribe", BindingFlags.Static | BindingFlags.NonPublic)!; + unsubscribe.Invoke(null, new object[] { subscriber }); + } + private static void ClearRegionName(FrameworkElement host) { if (host.ReadLocalValue(Region.RegionNameProperty) != DependencyProperty.UnsetValue) @@ -409,4 +742,61 @@ private static void ForceGarbageCollection(WeakReference weakReference) GC.Collect(); } } + + private sealed class PublicationOrderBlocker : IDisposable + { + private readonly string regionName; + + public PublicationOrderBlocker(string regionName) + { + this.regionName = regionName; + } + + public bool IsArmed { get; set; } + + public ManualResetEventSlim RemoveObserved { get; } = new(false); + + public ManualResetEventSlim ReplacementAddObserved { get; } = new(false); + + public ManualResetEventSlim ReleaseRemove { get; } = new(false); + + public void Handle(TChange change) + { + if (!IsArmed || change == null) + { + return; + } + + var changeType = change.GetType(); + var name = (string?)changeType.GetProperty("Name")?.GetValue(change); + var kind = changeType.GetProperty("Kind")?.GetValue(change)?.ToString(); + if (!string.Equals(name, regionName, StringComparison.Ordinal)) + { + return; + } + + if (string.Equals(kind, "Remove", StringComparison.Ordinal)) + { + RemoveObserved.Set(); + if (!ReleaseRemove.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("Timed out waiting to release declaration removal."); + } + + return; + } + + if (string.Equals(kind, "Add", StringComparison.Ordinal)) + { + ReplacementAddObserved.Set(); + } + } + + public void Dispose() + { + RemoveObserved.Dispose(); + ReplacementAddObserved.Dispose(); + ReleaseRemove.Dispose(); + } + } } From 49224a0460a52b07cd1ad29458e8a285f03fd8c7 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 01:08:12 +0800 Subject: [PATCH 09/42] feat: add explicit navigation routes --- Common/NavigationRouteRegistry.cs | 79 ++++++++++ Extensions/NavigationExtensions.cs | 137 ++++++++++++++++-- .../NavigationExtensionsTests.cs | 122 ++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 19 +++ 4 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 Common/NavigationRouteRegistry.cs create mode 100644 Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs create mode 100644 Tests/SimpleNavigation.Tests/TestTypes.cs diff --git a/Common/NavigationRouteRegistry.cs b/Common/NavigationRouteRegistry.cs new file mode 100644 index 0000000..5cb86c5 --- /dev/null +++ b/Common/NavigationRouteRegistry.cs @@ -0,0 +1,79 @@ +namespace SimpleNavigation.Common +{ + internal enum NavigationRouteKind + { + Page, + Content, + } + + internal sealed class NavigationRouteRegistration + { + public NavigationRouteRegistration( + NavigationRouteKind kind, + string key, + Type targetType) + { + Kind = kind; + Key = key; + TargetType = targetType; + } + + public NavigationRouteKind Kind { get; } + + public string Key { get; } + + public Type TargetType { get; } + } + + internal sealed class NavigationRouteRegistry + { + private readonly IReadOnlyDictionary pages; + private readonly IReadOnlyDictionary contents; + + public NavigationRouteRegistry(IEnumerable registrations) + { + pages = Build(registrations, NavigationRouteKind.Page); + contents = Build(registrations, NavigationRouteKind.Content); + } + + public Type GetRequiredPageType(string key) => + GetRequired(pages, key, "page"); + + public Type GetRequiredContentType(string key) => + GetRequired(contents, key, "content"); + + private static IReadOnlyDictionary Build( + IEnumerable registrations, + NavigationRouteKind kind) + { + var routes = new Dictionary(StringComparer.Ordinal); + foreach (var registration in registrations.Where(item => item.Kind == kind)) + { + routes.Add(registration.Key, registration.TargetType); + } + + return routes; + } + + private static Type GetRequired( + IReadOnlyDictionary routes, + string key, + string category) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException( + "Route key cannot be null or whitespace.", + nameof(key)); + } + + if (routes.TryGetValue(key, out var targetType)) + { + return targetType; + } + + throw new KeyNotFoundException( + $"No {category} route is registered for key '{key}'."); + } + } +} diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index 756a2fd..91d76c1 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -3,18 +3,133 @@ using SimpleNavigation.Common; using SimpleNavigation.Interface; using SimpleNavigation.Services; +using System.Windows; +using System.Windows.Controls; namespace SimpleNavigation.Extensions { - public static class NavigationExtensions - { - public static IServiceCollection RegisterNavigationService(this IServiceCollection serviceCollection) - { - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); - - return serviceCollection; - } - } + public static class NavigationExtensions + { + public static IServiceCollection RegisterNavigationService( + this IServiceCollection serviceCollection) + { + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(provider => + new NavigationRouteRegistry( + provider.GetServices())); + + return serviceCollection; + } + + public static IServiceCollection AddPage( + this IServiceCollection services, + string key) + where TPage : Page + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddPage( + this IServiceCollection services) + where TPage : Page + where TViewModel : class + { + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddPage( + this IServiceCollection services, + string key) + where TPage : Page + where TViewModel : class + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddContent( + this IServiceCollection services, + string key) + where TView : FrameworkElement + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddContent( + this IServiceCollection services) + where TView : FrameworkElement + where TViewModel : class + { + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddContent( + this IServiceCollection services, + string key) + where TView : FrameworkElement + where TViewModel : class + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + private static void AddRoute( + IServiceCollection services, + NavigationRouteKind kind, + string key, + Type targetType) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException( + "Route key cannot be null or whitespace.", + nameof(key)); + } + + var duplicate = services.Any(descriptor => + descriptor.ServiceType == typeof(NavigationRouteRegistration) && + descriptor.ImplementationInstance is NavigationRouteRegistration route && + route.Kind == kind && + string.Equals(route.Key, key, StringComparison.Ordinal)); + + if (duplicate) + { + throw new ArgumentException( + $"A {kind.ToString().ToLowerInvariant()} route with key '{key}' is already registered.", + nameof(key)); + } + + services.AddSingleton( + new NavigationRouteRegistration(kind, key, targetType)); + } + + private static void ValidateContentType(Type targetType) + { + if (typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Content type '{targetType.FullName}' cannot derive from Page or Window.", + nameof(targetType)); + } + } + } } diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs new file mode 100644 index 0000000..df58cda --- /dev/null +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; + +namespace SimpleNavigation.Tests; + +public sealed class NavigationExtensionsTests +{ + [Fact] + public void SingleGenericKeyOverloads_RegisterTransientViewsAndCoreServices() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddPage("page"); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.Same( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void DoubleGenericOverloads_RegisterViewAndViewModelWithoutSettingDataContext() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddPage(); + services.AddContent(); + using var provider = services.BuildServiceProvider(); + + var page = provider.GetRequiredService(); + var content = provider.GetRequiredService(); + + Assert.Null(page.DataContext); + Assert.Null(content.DataContext); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void RegistrationHelpers_PreserveEarlierSingletonRegistrations() + { + StaTest.Run(() => + { + var page = new FirstPage(); + var content = new TestContent(); + var viewModel = new TestViewModel(); + var services = new ServiceCollection(); + services.AddSingleton(page); + services.AddSingleton(content); + services.AddSingleton(viewModel); + + services.AddPage("page"); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.Same(page, provider.GetRequiredService()); + Assert.Same(content, provider.GetRequiredService()); + Assert.Same(viewModel, provider.GetRequiredService()); + }); + } + + [Fact] + public void DuplicateKeyWithinOneCategory_IsRejectedUsingOrdinalMatching() + { + var services = new ServiceCollection(); + services.AddPage("main"); + services.AddPage("Main"); + + var exception = Assert.Throws( + () => services.AddPage("main")); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void SameKeyAcrossPageAndContentCategories_IsAllowed() + { + var services = new ServiceCollection(); + + services.AddPage("main"); + services.AddContent("main"); + } + + [Fact] + public void ContentRegistration_RejectsPageAndWindowTypes() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddContent("page")); + Assert.Throws(() => services.AddContent("window")); + Assert.Throws(() => services.AddContent()); + Assert.Throws(() => services.AddContent("window-vm")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InvalidRouteKey_IsRejected(string? key) + { + var exception = Assert.Throws( + () => new ServiceCollection().AddPage(key!)); + + Assert.Equal("key", exception.ParamName); + } +} diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs new file mode 100644 index 0000000..48f9092 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -0,0 +1,19 @@ +using System.Windows.Controls; + +namespace SimpleNavigation.Tests; + +public sealed class FirstPage : Page +{ +} + +public sealed class SecondPage : Page +{ +} + +public sealed class TestContent : UserControl +{ +} + +public sealed class TestViewModel +{ +} From dc1961a823a4faf7ba35fba40b410330578f29b3 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 01:16:26 +0800 Subject: [PATCH 10/42] test: strengthen navigation registration coverage --- .../NavigationExtensionsTests.cs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index df58cda..7e849d9 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; using SimpleNavigation.Extensions; using SimpleNavigation.Interface; +using SimpleNavigation.Services; using SimpleNavigation.Tests.TestInfrastructure; using System.Windows; @@ -31,6 +33,63 @@ public void SingleGenericKeyOverloads_RegisterTransientViewsAndCoreServices() }); } + [Fact] + public void RegisterNavigationService_RegistersConcreteRegionManagerAsSingleton() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + Assert.IsType(first); + Assert.Same(first, second); + } + + [Fact] + public void RegisterNavigationService_RegistersInternalRouteRegistryAsSingleton() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + var descriptor = Assert.Single( + services, + item => item.ServiceType.FullName == + "SimpleNavigation.Common.NavigationRouteRegistry"); + using var provider = services.BuildServiceProvider(); + + var first = provider.GetRequiredService(descriptor.ServiceType); + var second = provider.GetRequiredService(descriptor.ServiceType); + + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + Assert.Same(first, second); + } + + [Fact] + public void RegisterNavigationService_PreservesEarlierCoreRegistrations() + { + var services = new ServiceCollection(); + var regionManager = new RegionManager(); + services.AddSingleton(regionManager); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + var existingDescriptors = services.ToArray(); + + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + + Assert.Equal(existingDescriptors.Length + 1, services.Count); + foreach (var descriptor in existingDescriptors) + { + Assert.Same( + descriptor, + Assert.Single(services, item => item.ServiceType == descriptor.ServiceType)); + } + + Assert.Same(regionManager, provider.GetRequiredService()); + } + [Fact] public void DoubleGenericOverloads_RegisterViewAndViewModelWithoutSettingDataContext() { @@ -88,6 +147,18 @@ public void DuplicateKeyWithinOneCategory_IsRejectedUsingOrdinalMatching() Assert.Equal("key", exception.ParamName); } + [Fact] + public void DuplicateContentKey_IsRejected() + { + var services = new ServiceCollection(); + services.AddContent("main"); + + var exception = Assert.Throws( + () => services.AddContent("main")); + + Assert.Equal("key", exception.ParamName); + } + [Fact] public void SameKeyAcrossPageAndContentCategories_IsAllowed() { @@ -119,4 +190,16 @@ public void InvalidRouteKey_IsRejected(string? key) Assert.Equal("key", exception.ParamName); } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InvalidContentRouteKey_IsRejected(string? key) + { + var exception = Assert.Throws( + () => new ServiceCollection().AddContent(key!)); + + Assert.Equal("key", exception.ParamName); + } } From b95538013fcc4173a6943257badbba2d0229f65b Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 01:27:49 +0800 Subject: [PATCH 11/42] test: cover navigation route lookup --- .../NavigationExtensionsTests.cs | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index 7e849d9..b261af0 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -4,6 +4,8 @@ using SimpleNavigation.Interface; using SimpleNavigation.Services; using SimpleNavigation.Tests.TestInfrastructure; +using System.Reflection; +using System.Runtime.ExceptionServices; using System.Windows; namespace SimpleNavigation.Tests; @@ -69,7 +71,7 @@ public void RegisterNavigationService_RegistersInternalRouteRegistryAsSingleton( public void RegisterNavigationService_PreservesEarlierCoreRegistrations() { var services = new ServiceCollection(); - var regionManager = new RegionManager(); + using var regionManager = new RegionManager(); services.AddSingleton(regionManager); services.AddSingleton(); services.AddSingleton(); @@ -90,6 +92,77 @@ public void RegisterNavigationService_PreservesEarlierCoreRegistrations() Assert.Same(regionManager, provider.GetRequiredService()); } + [Fact] + public void RouteRegistry_MapsRoutesAddedBeforeAndAfterCoreRegistration() + { + var services = new ServiceCollection(); + services.AddPage("shared"); + services.RegisterNavigationService(); + services.AddContent("shared"); + services.AddPage("second"); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "shared")); + Assert.Equal( + typeof(TestContent), + InvokeRouteLookup(registry, "GetRequiredContentType", "shared")); + Assert.Equal( + typeof(SecondPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "second")); + } + + [Fact] + public void RouteRegistry_UsesOrdinalCaseSensitiveKeys() + { + var services = new ServiceCollection(); + services.AddPage("main"); + services.AddPage("Main"); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "main")); + Assert.Equal( + typeof(SecondPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "Main")); + } + + [Fact] + public void RouteRegistry_UnknownPageAndContentKeysThrowKeyNotFoundException() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredPageType", "missing")); + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredContentType", "missing")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RouteRegistry_InvalidPageAndContentKeysThrowArgumentException(string? key) + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredPageType", key)); + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredContentType", key)); + } + [Fact] public void DoubleGenericOverloads_RegisterViewAndViewModelWithoutSettingDataContext() { @@ -202,4 +275,37 @@ public void InvalidContentRouteKey_IsRejected(string? key) Assert.Equal("key", exception.ParamName); } + + private static object GetRouteRegistry( + IServiceCollection services, + IServiceProvider provider) + { + var descriptor = Assert.Single( + services, + item => item.ServiceType.FullName == + "SimpleNavigation.Common.NavigationRouteRegistry"); + return provider.GetRequiredService(descriptor.ServiceType); + } + + private static Type InvokeRouteLookup( + object registry, + string methodName, + string? key) + { + var method = registry.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(method); + + try + { + return Assert.IsAssignableFrom( + method!.Invoke(registry, new object?[] { key })); + } + catch (TargetInvocationException exception) when (exception.InnerException != null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } } From 7a81e6d7f4b4b5381d8893b9688c27729399e466 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 01:36:18 +0800 Subject: [PATCH 12/42] feat: rebuild page navigation around regions --- Common/FrameRegionAdapter.cs | 20 +- Common/NavigationAwareNotifier.cs | 24 ++ Common/RegionHostAdapter.cs | 10 + Interface/INavigationAware.cs | 8 + Interface/IPageAware.cs | 8 +- Interface/IPageService.cs | 50 ++-- Services/PageService.cs | 150 +++++++---- Services/RegionService.cs | 40 --- .../PageServiceTests.cs | 238 ++++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 36 +++ 10 files changed, 457 insertions(+), 127 deletions(-) create mode 100644 Common/NavigationAwareNotifier.cs create mode 100644 Interface/INavigationAware.cs delete mode 100644 Services/RegionService.cs create mode 100644 Tests/SimpleNavigation.Tests/PageServiceTests.cs diff --git a/Common/FrameRegionAdapter.cs b/Common/FrameRegionAdapter.cs index f6c9f4c..d5ab1b8 100644 --- a/Common/FrameRegionAdapter.cs +++ b/Common/FrameRegionAdapter.cs @@ -3,7 +3,7 @@ namespace SimpleNavigation.Common; -internal sealed class FrameRegionAdapter : IRegionHostAdapter +internal sealed class FrameRegionAdapter : IPageRegionHostAdapter { public RegionHostKind Kind => RegionHostKind.Page; @@ -11,4 +11,22 @@ public bool CanHandle(FrameworkElement region) { return region is Frame; } + + public bool Navigate(Frame frame, Page page) + { + frame.Dispatcher.VerifyAccess(); + return frame.Navigate(page); + } + + public bool CanGoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + return frame.CanGoBack; + } + + public void GoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + frame.GoBack(); + } } diff --git a/Common/NavigationAwareNotifier.cs b/Common/NavigationAwareNotifier.cs new file mode 100644 index 0000000..cbf8200 --- /dev/null +++ b/Common/NavigationAwareNotifier.cs @@ -0,0 +1,24 @@ +using SimpleNavigation.Interface; +using System.Windows; + +namespace SimpleNavigation.Common; + +internal static class NavigationAwareNotifier +{ + public static void Notify( + FrameworkElement target, + DialogParameters? parameters) + { + if (target is INavigationAware targetAware) + { + targetAware.OnNavigated(parameters); + } + + var dataContext = target.DataContext; + if (dataContext is INavigationAware contextAware && + !ReferenceEquals(dataContext, target)) + { + contextAware.OnNavigated(parameters); + } + } +} diff --git a/Common/RegionHostAdapter.cs b/Common/RegionHostAdapter.cs index 88193b9..ecbce73 100644 --- a/Common/RegionHostAdapter.cs +++ b/Common/RegionHostAdapter.cs @@ -1,4 +1,5 @@ using System.Windows; +using System.Windows.Controls; namespace SimpleNavigation.Common; @@ -15,6 +16,15 @@ internal interface IRegionHostAdapter bool CanHandle(FrameworkElement region); } +internal interface IPageRegionHostAdapter : IRegionHostAdapter +{ + bool Navigate(Frame frame, Page page); + + bool CanGoBack(Frame frame); + + void GoBack(Frame frame); +} + internal static class RegionHostAdapterResolver { private static readonly IRegionHostAdapter[] HostAdapters = diff --git a/Interface/INavigationAware.cs b/Interface/INavigationAware.cs new file mode 100644 index 0000000..fcce7d1 --- /dev/null +++ b/Interface/INavigationAware.cs @@ -0,0 +1,8 @@ +using SimpleNavigation.Common; + +namespace SimpleNavigation.Interface; + +public interface INavigationAware +{ + void OnNavigated(DialogParameters? parameters); +} diff --git a/Interface/IPageAware.cs b/Interface/IPageAware.cs index 17b302b..50a4729 100644 --- a/Interface/IPageAware.cs +++ b/Interface/IPageAware.cs @@ -2,18 +2,12 @@ namespace SimpleNavigation.Interface { - public interface IPageAware + public interface IPageAware : INavigationAware { /// /// å½“é¡µé¢æŽ¥æ”¶æ¶ˆæ¯æ—¶çš„回调方法 /// /// 消æ¯å‚æ•° event Action? Receive; - - /// - /// 当页é¢å¯¼èˆªå®Œæˆæ—¶çš„回调方法 - /// - /// å¯¼èˆªå‚æ•° - void OnNavigated(DialogParameters? parameters); } } diff --git a/Interface/IPageService.cs b/Interface/IPageService.cs index cefd01a..1b9fe13 100644 --- a/Interface/IPageService.cs +++ b/Interface/IPageService.cs @@ -1,37 +1,27 @@ -using SimpleNavigation.Common; +using SimpleNavigation.Common; using System.Windows.Controls; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface; + +public interface IPageService { - public interface IPageService - { - /// - /// å¯¼èˆªåˆ°æŒ‡å®šé¡µé¢ - /// - /// 页é¢ç±»åž‹ - /// 区域åç§° - /// 页é¢å‚æ•° - void Navigate(string regionName, DialogParameters? parameters = null) where T : Page; + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); - /// - /// å¯¼èˆªåˆ°æŒ‡å®šé¡µé¢ - /// - /// 区域åç§° - /// 目标类型 - /// 页é¢å‚æ•° - void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); - /// - /// 获å–Page的父容器 - /// - /// 区域åç§° - /// Page的父容器 - Frame? GetRegion(string regionName); + void GoBack(string regionName); - /// - /// 导航返回上一页 - /// - /// 区域åç§° - void Goback(string region); - } + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); } diff --git a/Services/PageService.cs b/Services/PageService.cs index 60bfb45..672e15e 100644 --- a/Services/PageService.cs +++ b/Services/PageService.cs @@ -1,76 +1,128 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; using SimpleNavigation.Interface; -using System.Collections.Concurrent; using System.Windows.Controls; -namespace SimpleNavigation.Services +namespace SimpleNavigation.Services; + +public class PageService : IPageService { - public class PageService : IPageService + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public PageService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page + { + ValidateRegionName(regionName); + NavigateCore( + regionName, + provider.GetRequiredService(), + parameters); + } + + public void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null) { - private readonly ConcurrentDictionary regions = new(); - private readonly IServiceProvider provider; + ValidateRegionName(regionName); + ValidatePageType(targetType); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } - public PageService(IServiceProvider provider) + public void Navigate( + string regionName, + string key, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredPageType(key); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } + + public void GoBack(string regionName) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(frame); + if (adapter.CanGoBack(frame)) { - this.provider = provider; - RegionService.RegionRegisted += (regionName, frame) => RegisterRegion(regionName, frame); + adapter.GoBack(frame); } + } - public void RegisterRegion(string regionName, Frame frame) + [Obsolete("Use GoBack instead.")] + public void Goback(string regionName) + { + GoBack(regionName); + } + + private void NavigateCore( + string regionName, + Page page, + DialogParameters? parameters) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(frame); + if (adapter.Navigate(frame, page)) { - if (!string.IsNullOrWhiteSpace(regionName) && frame != null) - regions[regionName] = frame; + NavigationAwareNotifier.Notify(page, parameters); } + } - public Frame? GetRegion(string regionName) + private Frame GetRequiredFrame(string regionName) + { + ValidateRegionName(regionName); + var region = regionManager.GetRegion(regionName); + if (region is Frame frame) { - regions.TryGetValue(regionName, out var frame); return frame; } - public void Goback(string region) + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a Frame but was '{actual}'."); + } + + private static void ValidatePageType(Type targetType) + { + if (targetType == null) { - if (regions.TryGetValue(region, out var frame)) - { - if (frame.CanGoBack) - frame.GoBack(); - } + throw new ArgumentNullException(nameof(targetType)); } - public void Navigate(string regionName, DialogParameters? parameters = null) where T : Page + if (!typeof(Page).IsAssignableFrom(targetType)) { - var region = GetRegion(regionName); - if (region != null) - { - var page = provider.GetRequiredService(); - - //var oldContent = region.Content; - - region.Navigate(page); - - if (page.DataContext is IPageAware pA && parameters != null) - { - pA.OnNavigated(parameters); - } - } + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Page.", + nameof(targetType)); } + } - public void Navigate(string regionName, Type targetType, DialogParameters? parameters = null) + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) { - if (targetType.IsSubclassOf(typeof(Page))) - { - var region = GetRegion(regionName); - if (region != null) - { - var page = provider.GetRequiredService(targetType) as Page; - region.Navigate(page); - if (page?.DataContext is IPageAware pA && parameters != null) - { - pA.OnNavigated(parameters); - } - } - } + throw new ArgumentException( + "Region name cannot be null or whitespace.", + nameof(regionName)); } } } diff --git a/Services/RegionService.cs b/Services/RegionService.cs deleted file mode 100644 index 34455ab..0000000 --- a/Services/RegionService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace SimpleNavigation.Services -{ - /// - /// 维护区域功能 - /// - public static class RegionService - { - // å…许在XAML中使用RegionService注册导航区域,注册完æˆåŽä¼šè§¦å‘事件,导航æœåŠ¡ä¼šè®¢é˜…è¯¥äº‹ä»¶ä»¥å®ŒæˆåŒºåŸŸçš„æ³¨å†Œã€‚ - public static event Action? RegionRegisted; - - public static string GetRegionName(DependencyObject obj) - { - return (string)obj.GetValue(RegionNameProperty); - } - - public static void SetRegionName(DependencyObject obj, string value) - { - obj.SetValue(RegionNameProperty, value); - } - - // 使用附加属性的方å¼å…许在XAML中为控件指定RegionName,RegionService会监å¬è¯¥å±žæ€§çš„å˜åŒ–以完æˆå¯¼èˆªåŒºåŸŸçš„æ³¨å†Œã€‚ - public static readonly DependencyProperty RegionNameProperty = - DependencyProperty.RegisterAttached("RegionName", typeof(string), typeof(RegionService), new PropertyMetadata("", OnRegionNameChanged)); - - - private static void OnRegionNameChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is not Frame frame) throw new InvalidOperationException("RegionName can only be attached to Frame controls."); - - var regionName = e.NewValue as string; - - if (string.IsNullOrWhiteSpace(regionName)) throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); - - RegionRegisted?.Invoke(regionName, frame); - } - } -} diff --git a/Tests/SimpleNavigation.Tests/PageServiceTests.cs b/Tests/SimpleNavigation.Tests/PageServiceTests.cs new file mode 100644 index 0000000..7fd810d --- /dev/null +++ b/Tests/SimpleNavigation.Tests/PageServiceTests.cs @@ -0,0 +1,238 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows.Controls; +using System.Windows.Navigation; + +namespace SimpleNavigation.Tests; + +public class PageServiceTests +{ + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + StaTest.PumpUntil(() => frame.Content is FirstPage); + Assert.IsType(frame.Content); + + service.Navigate("main", typeof(SecondPage)); + StaTest.PumpUntil(() => frame.Content is SecondPage); + Assert.IsType(frame.Content); + }); + } + + [Fact] + public void StringNavigationUsesCaseSensitiveRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new FirstPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddPage("first"); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main", "first"); + + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, expected)); + Assert.Same(expected, frame.Content); + Assert.Throws(() => service.Navigate("main", "First")); + }); + } + + [Fact] + public void ViewAndDataContextReceiveParametersOnceEach() + { + StaTest.Run(() => + { + var viewModel = new AwareViewModel(); + var page = new AwarePage { DataContext = viewModel }; + var parameters = new DialogParameters("id", 7); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(1, page.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, page.Parameters); + Assert.Same(parameters, viewModel.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NullParametersStillTriggerAwarenessAndSameInstanceIsDeduplicated() + { + StaTest.Run(() => + { + var page = new AwarePage(); + page.DataContext = page; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService().Navigate("main"); + + Assert.Equal(1, page.CallCount); + Assert.Null(page.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void InvalidInputsFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var contentHost = new ContentControl(); + provider.GetRequiredService().RegisterRegion("content", contentHost); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Navigate(" ")); + Assert.Throws(() => service.Navigate("content", (Type)null!)); + Assert.Throws(() => service.Navigate("content", typeof(TestContent))); + Assert.Throws(() => service.Navigate("missing")); + Assert.Throws(() => service.Navigate("content")); + Assert.Throws(() => service.Navigate("content", "unknown")); + GC.KeepAlive(contentHost); + }); + } + + [Fact] + public void GoBackUsesTheFrameJournalAndLegacyMethodForwards() + { + StaTest.Run(() => + { + var first = new FirstPage(); + var second = new SecondPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(first); + services.AddSingleton(second); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.GoBack("main"); + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, second)); + Assert.True(frame.CanGoBack); + + service.GoBack("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + Assert.Same(first, frame.Content); + + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, second)); + +#pragma warning disable CS0618 + service.Goback("main"); +#pragma warning restore CS0618 + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + Assert.Same(first, frame.Content); + }); + } + + [Fact] + public void MissingDiRegistrationKeepsTheContainerException() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Navigate("main")); + + Assert.Contains(typeof(FirstPage).FullName!, exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void AwarenessExceptionPropagatesToTheCaller() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new ThrowingAwarePage()); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Navigate("main")); + + Assert.Equal("awareness failed", exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NavigationRequiresTheHostDispatcherThread() + { + ServiceProvider? provider = null; + IPageService? service = null; + Frame? frame = null; + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new FirstPage()); + provider = services.BuildServiceProvider(); + frame = RegisterFrame(provider, "main"); + service = provider.GetRequiredService(); + }); + + try + { + Assert.Throws(() => service!.Navigate("main")); + GC.KeepAlive(frame); + } + finally + { + provider!.Dispose(); + } + } + + private static Frame RegisterFrame(IServiceProvider provider, string name) + { + var frame = new Frame + { + JournalOwnership = JournalOwnership.OwnsJournal, + NavigationUIVisibility = NavigationUIVisibility.Hidden + }; + provider.GetRequiredService().RegisterRegion(name, frame); + return frame; + } +} diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index 48f9092..e3244b6 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -1,3 +1,5 @@ +using SimpleNavigation.Common; +using SimpleNavigation.Interface; using System.Windows.Controls; namespace SimpleNavigation.Tests; @@ -17,3 +19,37 @@ public sealed class TestContent : UserControl public sealed class TestViewModel { } + +public sealed class AwareViewModel : INavigationAware +{ + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwarePage : Page, INavigationAware +{ + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class ThrowingAwarePage : Page, INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) + { + throw new InvalidOperationException("awareness failed"); + } +} From 446e754fda1b4e5083efd0779d126acee968e9a4 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 01:54:28 +0800 Subject: [PATCH 13/42] feat: add content navigation service --- Common/ContentControlRegionAdapter.cs | 8 +- Common/RegionHostAdapter.cs | 5 + Extensions/NavigationExtensions.cs | 1 + Interface/IContentService.cs | 22 ++ Services/ContentService.cs | 113 ++++++++ .../ContentServiceTests.cs | 268 ++++++++++++++++++ .../NavigationExtensionsTests.cs | 1 + Tests/SimpleNavigation.Tests/TestTypes.cs | 21 ++ 8 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 Interface/IContentService.cs create mode 100644 Services/ContentService.cs create mode 100644 Tests/SimpleNavigation.Tests/ContentServiceTests.cs diff --git a/Common/ContentControlRegionAdapter.cs b/Common/ContentControlRegionAdapter.cs index f23f585..e946b97 100644 --- a/Common/ContentControlRegionAdapter.cs +++ b/Common/ContentControlRegionAdapter.cs @@ -3,7 +3,7 @@ namespace SimpleNavigation.Common; -internal sealed class ContentControlRegionAdapter : IRegionHostAdapter +internal sealed class ContentControlRegionAdapter : IContentRegionHostAdapter { public RegionHostKind Kind => RegionHostKind.Content; @@ -11,4 +11,10 @@ public bool CanHandle(FrameworkElement region) { return region is ContentControl && region is not Frame; } + + public void Present(ContentControl host, FrameworkElement content) + { + host.Dispatcher.VerifyAccess(); + host.Content = content; + } } diff --git a/Common/RegionHostAdapter.cs b/Common/RegionHostAdapter.cs index ecbce73..5088b18 100644 --- a/Common/RegionHostAdapter.cs +++ b/Common/RegionHostAdapter.cs @@ -25,6 +25,11 @@ internal interface IPageRegionHostAdapter : IRegionHostAdapter void GoBack(Frame frame); } +internal interface IContentRegionHostAdapter : IRegionHostAdapter +{ + void Present(ContentControl host, FrameworkElement content); +} + internal static class RegionHostAdapterResolver { private static readonly IRegionHostAdapter[] HostAdapters = diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index 91d76c1..6389f69 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -15,6 +15,7 @@ public static IServiceCollection RegisterNavigationService( { serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(provider => diff --git a/Interface/IContentService.cs b/Interface/IContentService.cs new file mode 100644 index 0000000..eaccde1 --- /dev/null +++ b/Interface/IContentService.cs @@ -0,0 +1,22 @@ +using SimpleNavigation.Common; +using System.Windows; + +namespace SimpleNavigation.Interface; + +public interface IContentService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); +} diff --git a/Services/ContentService.cs b/Services/ContentService.cs new file mode 100644 index 0000000..743f247 --- /dev/null +++ b/Services/ContentService.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Interface; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Services; + +public class ContentService : IContentService +{ + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public ContentService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement + { + ValidateRegionName(regionName); + ValidateContentType(typeof(TContent)); + NavigateCore( + regionName, + provider.GetRequiredService(), + parameters); + } + + public void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + public void Navigate( + string regionName, + string key, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredContentType(key); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + private void NavigateCore( + string regionName, + FrameworkElement content, + DialogParameters? parameters) + { + var host = GetRequiredHost(regionName); + var adapter = (IContentRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(host); + adapter.Present(host, content); + NavigationAwareNotifier.Notify(content, parameters); + } + + private ContentControl GetRequiredHost(string regionName) + { + var region = regionManager.GetRegion(regionName); + if (region is ContentControl host && region is not Frame) + { + return host; + } + + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a non-Frame ContentControl but was '{actual}'."); + } + + private static void ValidateContentType(Type targetType) + { + if (targetType == null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + if (!typeof(FrameworkElement).IsAssignableFrom(targetType) || + typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Target type '{targetType.FullName}' must be a non-Page, non-Window FrameworkElement.", + nameof(targetType)); + } + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException( + "Region name cannot be null or whitespace.", + nameof(regionName)); + } + } +} diff --git a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs new file mode 100644 index 0000000..c6287f5 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs @@ -0,0 +1,268 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests; + +public class ContentServiceTests +{ + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + Assert.IsType(host.Content); + + service.Navigate("main", typeof(Grid)); + Assert.IsType(host.Content); + }); + } + + [Fact] + public void StringNavigationUsesCaseSensitiveRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new TestContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main", "content"); + + Assert.Same(expected, host.Content); + Assert.Throws(() => service.Navigate("main", "Content")); + }); + } + + [Fact] + public void ViewThenDistinctDataContextReceiveTheSameParameters() + { + StaTest.Run(() => + { + var calls = new List(); + var viewModel = new RecordingAware("context", calls); + var content = new RecordingAwareContent("view", calls) + { + DataContext = viewModel, + }; + var parameters = new DialogParameters("id", 9); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(new[] { "view", "context" }, calls); + Assert.Same(parameters, content.Parameters); + Assert.Same(parameters, viewModel.Parameters); + Assert.Same(content, host.Content); + }); + } + + [Fact] + public void NullParametersStillNotifyAndSameInstanceIsDeduplicated() + { + StaTest.Run(() => + { + var content = new AwareContent(); + content.DataContext = content; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main"); + + Assert.Equal(1, content.CallCount); + Assert.Null(content.Parameters); + Assert.Same(content, host.Content); + }); + } + + [Fact] + public void InvalidInputsAndUnsupportedTargetsFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = new Frame(); + provider.GetRequiredService().RegisterRegion("frame", frame); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Navigate(" ")); + Assert.Throws(() => service.Navigate("frame", (Type)null!)); + Assert.Throws(() => service.Navigate("frame", typeof(string))); + Assert.Throws(() => service.Navigate("frame")); + Assert.Throws(() => service.Navigate("frame", typeof(Window))); + Assert.Throws(() => service.Navigate("frame")); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void MissingRegionUnknownKeyAndMissingDiFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("missing")); + Assert.Throws( + () => service.Navigate("main", "unknown")); + var exception = Assert.Throws( + () => service.Navigate("main", typeof(Grid))); + Assert.Contains(typeof(Grid).FullName!, exception.Message); + GC.KeepAlive(host); + }); + } + + [Fact] + public void DoubleGenericRegistrationWithoutKeyDoesNotCreateARoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddContent(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + Assert.Throws(() => + provider.GetRequiredService() + .Navigate("main", "content")); + GC.KeepAlive(host); + }); + } + + [Fact] + public void AwarenessExceptionPropagatesAfterPresentation() + { + StaTest.Run(() => + { + var content = new ThrowingAwareContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + var exception = Assert.Throws(() => + provider.GetRequiredService() + .Navigate("main")); + + Assert.Equal("awareness failed", exception.Message); + Assert.Same(content, host.Content); + }); + } + + [Fact] + public void NavigationRequiresTheHostDispatcherThread() + { + ServiceProvider? provider = null; + IContentService? service = null; + ContentControl? host = null; + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new TestContent()); + provider = services.BuildServiceProvider(); + host = RegisterContentHost(provider, "main"); + service = provider.GetRequiredService(); + }); + + try + { + Assert.Throws(() => + service!.Navigate("main")); + GC.KeepAlive(host); + } + finally + { + provider!.Dispose(); + } + } + + private static ContentControl RegisterContentHost( + IServiceProvider provider, + string name) + { + var host = new ContentControl(); + provider.GetRequiredService().RegisterRegion(name, host); + return host; + } + + private sealed class RecordingAwareContent : UserControl, INavigationAware + { + private readonly string name; + private readonly IList calls; + + public RecordingAwareContent(string name, IList calls) + { + this.name = name; + this.calls = calls; + } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + calls.Add(name); + Parameters = parameters; + } + } + + private sealed class RecordingAware : INavigationAware + { + private readonly string name; + private readonly IList calls; + + public RecordingAware(string name, IList calls) + { + this.name = name; + this.calls = calls; + } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + calls.Add(name); + Parameters = parameters; + } + } +} diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index b261af0..f8344f9 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -75,6 +75,7 @@ public void RegisterNavigationService_PreservesEarlierCoreRegistrations() services.AddSingleton(regionManager); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); var existingDescriptors = services.ToArray(); diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index e3244b6..ce81e81 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -46,6 +46,27 @@ public void OnNavigated(DialogParameters? parameters) } } +public sealed class AwareContent : UserControl, INavigationAware +{ + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class ThrowingAwareContent : UserControl, INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) + { + throw new InvalidOperationException("awareness failed"); + } +} + public sealed class ThrowingAwarePage : Page, INavigationAware { public void OnNavigated(DialogParameters? parameters) From 0a81c2f39ee1259492c1475fa2c1680ff68379b1 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 02:20:39 +0800 Subject: [PATCH 14/42] fix: generalize content host adapter --- Common/ContentControlRegionAdapter.cs | 11 +++- Common/RegionHostAdapter.cs | 2 +- Services/ContentService.cs | 18 +++-- .../ContentServiceTests.cs | 66 ++++++++++++++++++- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/Common/ContentControlRegionAdapter.cs b/Common/ContentControlRegionAdapter.cs index e946b97..2545cbb 100644 --- a/Common/ContentControlRegionAdapter.cs +++ b/Common/ContentControlRegionAdapter.cs @@ -12,9 +12,16 @@ public bool CanHandle(FrameworkElement region) return region is ContentControl && region is not Frame; } - public void Present(ContentControl host, FrameworkElement content) + public void Present(FrameworkElement host, FrameworkElement content) { + if (host is not ContentControl contentControl || host is Frame) + { + throw new ArgumentException( + $"Host type '{host.GetType().FullName}' must be a non-Frame ContentControl.", + nameof(host)); + } + host.Dispatcher.VerifyAccess(); - host.Content = content; + contentControl.Content = content; } } diff --git a/Common/RegionHostAdapter.cs b/Common/RegionHostAdapter.cs index 5088b18..a102c6d 100644 --- a/Common/RegionHostAdapter.cs +++ b/Common/RegionHostAdapter.cs @@ -27,7 +27,7 @@ internal interface IPageRegionHostAdapter : IRegionHostAdapter internal interface IContentRegionHostAdapter : IRegionHostAdapter { - void Present(ContentControl host, FrameworkElement content); + void Present(FrameworkElement host, FrameworkElement content); } internal static class RegionHostAdapterResolver diff --git a/Services/ContentService.cs b/Services/ContentService.cs index 743f247..46fbffc 100644 --- a/Services/ContentService.cs +++ b/Services/ContentService.cs @@ -65,23 +65,27 @@ private void NavigateCore( DialogParameters? parameters) { var host = GetRequiredHost(regionName); - var adapter = (IContentRegionHostAdapter) - RegionHostAdapterResolver.GetRequired(host); + var hostAdapter = RegionHostAdapterResolver.GetRequired(host); + if (hostAdapter is not IContentRegionHostAdapter adapter) + { + throw new InvalidOperationException( + $"Region '{regionName}' does not support content navigation."); + } + adapter.Present(host, content); NavigationAwareNotifier.Notify(content, parameters); } - private ContentControl GetRequiredHost(string regionName) + private FrameworkElement GetRequiredHost(string regionName) { var region = regionManager.GetRegion(regionName); - if (region is ContentControl host && region is not Frame) + if (region != null) { - return host; + return region; } - var actual = region?.GetType().FullName ?? "missing"; throw new InvalidOperationException( - $"Region '{regionName}' must be a non-Frame ContentControl but was '{actual}'."); + $"Region '{regionName}' is not registered."); } private static void ValidateContentType(Type targetType) diff --git a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs index c6287f5..d12e74e 100644 --- a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs @@ -10,6 +10,20 @@ namespace SimpleNavigation.Tests; public class ContentServiceTests { + [Fact] + public void ContentHostAdapterContractAcceptsAnyFrameworkElementHost() + { + var adapterType = typeof(IContentService).Assembly.GetType( + "SimpleNavigation.Common.IContentRegionHostAdapter", + throwOnError: true)!; + var present = adapterType.GetMethod("Present"); + + Assert.NotNull(present); + Assert.Equal( + typeof(FrameworkElement), + present!.GetParameters()[0].ParameterType); + } + [Fact] public void GenericAndTypeNavigationDoNotRequireRoutes() { @@ -189,6 +203,38 @@ public void AwarenessExceptionPropagatesAfterPresentation() }); } + [Fact] + public void HostPresentationExceptionPropagatesWithoutNotifyingAwareness() + { + StaTest.Run(() => + { + var content = new AwareContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost( + provider, + "main", + new ThrowingContentControl()); + var regionManager = provider.GetRequiredService(); + + try + { + var exception = Assert.Throws(() => + provider.GetRequiredService() + .Navigate("main")); + + Assert.Equal("presentation failed", exception.Message); + Assert.Equal(0, content.CallCount); + } + finally + { + regionManager.UnregisterRegion("main", host); + } + }); + } + [Fact] public void NavigationRequiresTheHostDispatcherThread() { @@ -221,11 +267,29 @@ private static ContentControl RegisterContentHost( IServiceProvider provider, string name) { - var host = new ContentControl(); + return RegisterContentHost(provider, name, new ContentControl()); + } + + private static TContentControl RegisterContentHost( + IServiceProvider provider, + string name, + TContentControl host) + where TContentControl : ContentControl + { provider.GetRequiredService().RegisterRegion(name, host); return host; } + private sealed class ThrowingContentControl : ContentControl + { + protected override void OnContentChanged( + object oldContent, + object newContent) + { + throw new InvalidOperationException("presentation failed"); + } + } + private sealed class RecordingAwareContent : UserControl, INavigationAware { private readonly string name; From 8df8be4117ca9b32baca353d5c076cdd5b6c86a9 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 02:29:02 +0800 Subject: [PATCH 15/42] docs: document rebuilt navigation APIs --- README.md | 362 +++++++++++++++++++++++------------------------------- 1 file changed, 155 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index fe130ad..939f80d 100644 --- a/README.md +++ b/README.md @@ -2,327 +2,275 @@ [![NuGet](https://img.shields.io/nuget/v/Junevy.SimpleNavigation.svg)](https://www.nuget.org/packages/Junevy.SimpleNavigation/) -一个超轻é‡çº§çš„ WPF 导航框架,基于 `Microsoft.Extensions.DependencyInjection` 构建,让 WPF 应用的页é¢å¯¼èˆªå’Œçª—å£ç®¡ç†å˜å¾—简å•直观。 +SimpleNavigation 是一个基于 `Microsoft.Extensions.DependencyInjection` 的轻é‡çº§ WPF å¯¼èˆªç±»åº“ï¼Œæ”¯æŒ .NET 8 WPF 与 .NET Framework 4.8。 ---- +## 功能概览 -## é¡¹ç›®ä»‹ç» +- `PageService`:在命åçš„ `Frame` 区域中导航 `Page`,支æŒè¿”回上一页。 +- `ContentService`:在命åçš„éž `Frame` `ContentControl` 区域中显示 `UserControl`ã€è‡ªå®šä¹‰ `ContentControl` æˆ–å…¶ä»–éž `Page`ã€éž `Window` çš„ `FrameworkElement`。 +- `DialogService`:通过 DI 创建并显示 `Window`ï¼Œæ”¯æŒæ¨¡æ€/éžæ¨¡æ€çª—å£ã€å‚数传递与结果返回。 +- `RegionManager`ï¼šç»Ÿä¸€ç®¡ç† XAML å£°æ˜Žæˆ–ä»£ç æ³¨å†Œçš„命å区域,并以弱引用ä¿å­˜åŒºåŸŸå®¿ä¸»ã€‚ -SimpleNavigation æ˜¯ä¸€ä¸ªå— Prism å¯å‘çš„ WPF 导航库,但去除了 Prism çš„å¤æ‚性,仅ä¿ç•™æœ€æ ¸å¿ƒçš„å¯¼èˆªèƒ½åŠ›ã€‚æ•´ä¸ªæ¡†æž¶ä»…åŒ…å« 11 个 C# æºæ–‡ä»¶ï¼Œé›¶é¢å¤–ä¾èµ–(åªä¾èµ–微软官方的 DI 容器),æä¾›ä¸‰å¤§æ ¸å¿ƒåŠŸèƒ½ï¼š - -- **区域(Region)页é¢å¯¼èˆª** — 在命å `Frame` 区域内进行 `Page` 的导航与回退 -- **窗å£ï¼ˆDialog)管ç†** — 打开 WPF `Window`ï¼Œæ”¯æŒæ¨¡æ€/éžæ¨¡æ€ã€å‚数传递与结果返回 -- **å¯¼èˆªå‚æ•°ä¼ é€’** — 通过 `DialogParameters` 在页é¢å’Œçª—å£ä¹‹é—´ä¼ é€’å¼ºç±»åž‹å‚æ•° - -é…åˆ `CommunityToolkit.Mvvm` 使用效果更佳。 - ---- +导航目标全部由应用的 DI 容器创建。类库ä¸è®¾ç½® `DataContext`,因此 View 与 ViewModel 的构造注入和绑定方å¼ä»ç”±åº”用决定。 ## çŽ¯å¢ƒè¦æ±‚ -| 目标框架 | 说明 | -|----------|------| -| `.NET 8.0` (net8.0-windows) | 现代 .NET WPF 应用 | -| `.NET Framework 4.8` (net48) | 传统 .NET Framework WPF 应用 | - -å¼€å‘环境:Visual Studio 2022 (v17.14+),C# 13。 - ---- - -## ä¾èµ–ä¿¡æ¯ - -### NuGet 包 - -``` -Junevy.SimpleNavigation -``` - -### 框架ä¾èµ– - -| 目标框架 | ä¾èµ–项 | 版本 | -|----------|--------|------| -| net8.0-windows | `Microsoft.Extensions.DependencyInjection` | 8.0.0 | -| net48 | `Microsoft.Extensions.DependencyInjection` | 6.0.1 | - -ä»…ä¾èµ–微软官方的 DI 容器,无需 Prism 或其他é‡åž‹æ¡†æž¶ã€‚ - ---- - -## 功能简介与演示 - -### 1. 项目结构 - -``` -SimpleNavigation/ -├── Common/ -│ ├── DialogManager.cs # 窗å£ç”Ÿå‘½å‘¨æœŸç®¡ç†ï¼ŒWeakReference ç¼“å­˜é˜²æ­¢å†…å­˜æ³„æ¼ -│ └── DialogParameters.cs # å¯¼èˆªå‚æ•°å¯¹è±¡ï¼Œæ”¯æŒå­—å…¸/索引/é”®å€¼ä¸‰ç§æž„造 -├── Extensions/ -│ └── NavigationExtensions.cs # DI 容器注册扩展方法 -├── Interface/ -│ ├── IPageService.cs # 页é¢å¯¼èˆªæœåŠ¡æŽ¥å£ -│ ├── IPageAware.cs # 页颿„ŸçŸ¥æŽ¥å£ï¼ˆæŽ¥æ”¶å¯¼èˆªäº‹ä»¶ï¼‰ -│ ├── IDialogService.cs # çª—å£æœåŠ¡æŽ¥å£ -│ ├── IDialogAware.cs # çª—å£æ„ŸçŸ¥æŽ¥å£ï¼ˆæŽ¥æ”¶å¯¼èˆªäº‹ä»¶ + 请求关闭) -│ └── IDialogManager.cs # 窗å£å®žä¾‹ç®¡ç†æŽ¥å£ -└── Services/ - ├── PageService.cs # 页é¢å¯¼èˆªå®žçް - ├── DialogService.cs # 窗å£ç®¡ç†å®žçް - └── RegionService.cs # 区域附加属性(XAML å£°æ˜Žå¼æ³¨å†Œï¼‰ -``` - -### 2. 快速开始 +| 目标框架 | DI ä¾èµ– | +| --- | --- | +| `net8.0-windows` | `Microsoft.Extensions.DependencyInjection` 8.0.0 | +| `net48` | `Microsoft.Extensions.DependencyInjection` 6.0.1 | -#### 2.1 安装 - -通过 NuGet 安装: +安装 NuGet 包: ```bash dotnet add package Junevy.SimpleNavigation ``` -或使用 Package Manager: +## 项目结构 +```text +SimpleNavigation/ + Interface/ + IRegionManager.cs # 区域注册与查询 + IPageService.cs # Page 导航 + IContentService.cs # FrameworkElement 内容导航 + INavigationAware.cs # 导航完æˆé€šçŸ¥ + IDialogService.cs # Window 显示æœåŠ¡ + IDialogAware.cs # Window 导航与关闭通知 + IDialogManager.cs # Window å®žä¾‹ç®¡ç† + Services/ + Region.cs # RegionName 附加属性 + PageService.cs # Frame/Page 导航实现 + ContentService.cs # ContentControl 内容导航实现 + DialogService.cs # Window 显示实现 + Common/ + RegionManager.cs # 命ååŒºåŸŸç®¡ç† + DialogManager.cs # Window å®žä¾‹ç®¡ç† + DialogParameters.cs # å¯¼èˆªå‚æ•° + Extensions/ + NavigationExtensions.cs # DI 与路由注册扩展 ``` -Install-Package Junevy.SimpleNavigation -``` - -#### 2.2 注册导航æœåŠ¡ -在 `App.xaml.cs` 中使用一行代ç å®Œæˆæ‰€æœ‰æœåŠ¡æ³¨å†Œï¼š +## 注册æœåŠ¡å’Œç›®æ ‡ ```csharp using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Extensions; -public partial class App : Application -{ - public IServiceProvider Provider { get; private set; } - - public void InitialProvider() - { - var container = new ServiceCollection(); +var services = new ServiceCollection(); +services.RegisterNavigationService(); - // 注册导航æœåŠ¡ï¼ˆä¸€è¡Œæ³¨å†Œ IDialogService / IPageService / IDialogManager) - container.RegisterNavigationService(); +// 普通 DI æ³¨å†Œè¶³ä»¥æ”¯æŒæ³›åž‹å¯¼èˆªå’Œ Type 导航。 +services.AddTransient(); +services.AddSingleton(); - // 注册你的页é¢ã€çª—å£å’Œ ViewModel - container.AddTransient(); - container.AddSingleton(); - container.AddTransient(); - container.AddSingleton(); - container.AddSingleton((p) => new TestPage() { ShowsNavigationUI = false }); +// 路由扩展å¯åŒæ—¶æ³¨å†Œ View/ViewModel,并å¯é€‰æ‹©æ·»åŠ å­—ç¬¦ä¸²åˆ«å。 +services.AddPage("settings"); +services.AddPage("reports"); +services.AddContent(); +services.AddContent("status"); - Provider = container.BuildServiceProvider(); - } -} +var provider = services.BuildServiceProvider(); ``` -#### 2.3 注册导航区域(Region) +`AddPage` 与 `AddContent` 使用 `TryAddTransient` 注册 View å’Œå¯é€‰çš„ ViewModel,ä¸ä¼šè¦†ç›–åœ¨å®ƒä»¬ä¹‹å‰æ·»åŠ çš„æ³¨å†Œã€‚éœ€è¦è‡ªå®šä¹‰ç”Ÿå‘½å‘¨æœŸæˆ–工厂时,应先使用标准 DI 方法注册对应æœåŠ¡ã€‚è¿™äº›æ‰©å±•æ–¹æ³•ä¸ä¼šåˆ›å»ºæˆ–设置 View çš„ `DataContext`。 -在 XAML 中通过附加属性声明导航区域: +åªæœ‰å¸¦ `string key` çš„é‡è½½ä¼šæ³¨å†Œè·¯ç”±åˆ«å。Page 与 Content çš„ key 空间相互独立ã€åŒºåˆ†å¤§å°å†™ï¼ŒåŒä¸€ç©ºé—´å†…ä¸èƒ½é‡å¤æ³¨å†Œ key。 -```xml - +## 声明导航区域 +```xml + - + - - - - - - + - - + ``` -> `RegionService.RegionName` åªèƒ½é™„加到 `Frame` 控件上,å¦åˆ™ä¼šæŠ›å‡ºå¼‚常。 +`PageService` 的区域宿主必须是 `Frame`。`ContentService` 当å‰è¦æ±‚åŒºåŸŸå®¿ä¸»æ˜¯éž `Frame` çš„ `ContentControl`。也å¯ä»¥é€šè¿‡ `IRegionManager.RegisterRegion(...)` æ˜¾å¼æ³¨å†Œå®¿ä¸»ï¼Œå¹¶é€šè¿‡ `GetRegion(...)` 查询。 -### 3. 页é¢å¯¼èˆª +## Page 导航 -#### 3.1 å¯¼èˆªåˆ°æŒ‡å®šé¡µé¢ +`IPageService` æä¾›æ³›åž‹ã€`Type` 和字符串 key 三ç§å¯¼èˆªæ–¹å¼ï¼š ```csharp -using SimpleNavigation.Interface; using SimpleNavigation.Common; +using SimpleNavigation.Interface; -public class MainWindowViewModel +public sealed class MainViewModel { private readonly IPageService pageService; - public MainWindowViewModel(IPageService pageService) + public MainViewModel(IPageService pageService) { this.pageService = pageService; } - [RelayCommand] - public void RegionTest() + public void OpenPages() { - // 泛型方å¼å¯¼èˆª - pageService.Navigate("Main"); - - // 另傿•°å¯¼èˆª - var parameters = new DialogParameters("key", "value"); - pageService.Navigate("Main", parameters); + var parameters = new DialogParameters("id", 42); - // Type æ–¹å¼å¯¼èˆª - pageService.Navigate("Main", typeof(TestPage), parameters); + pageService.Navigate("Pages", parameters); + pageService.Navigate("Pages", typeof(HomePage), parameters); + pageService.Navigate("Pages", "settings", parameters); } - [RelayCommand] - public void GoBack() + public void Back() { - // 返回上一页 - pageService.Goback("Main"); + pageService.GoBack("Pages"); } } ``` -#### 3.2 é¡µé¢æŽ¥æ”¶å‚æ•° +泛型é‡è½½ç›´æŽ¥ä»Ž DI è§£æžæ³›åž‹ç±»åž‹ï¼Œ`Type` é‡è½½ç›´æŽ¥ä»Ž DI è§£æžä¼ å…¥ç±»åž‹ã€‚åªæœ‰å­—符串é‡è½½å…ˆä»Ž Page 路由字典å–得最终类型;å–得类型åŽä»é€šè¿‡æ™®é€š DI è§£æž Page 实例。 -让 ViewModel 实现 `IPageAware` 接å£ï¼š +## Content 导航 + +`IContentService` åŒæ ·æä¾›ä¸‰ç§å¯¼èˆªæ–¹å¼ï¼š ```csharp using SimpleNavigation.Common; using SimpleNavigation.Interface; -public class TestViewModel : IPageAware +public sealed class ShellViewModel { - public event Action? Receive; + private readonly IContentService contentService; - public void OnNavigated(DialogParameters? parameters) + public ShellViewModel(IContentService contentService) { - if (parameters != null) - { - var value = parameters.Get("key"); - // 处ç†å¯¼èˆªå‚æ•° - } + this.contentService = contentService; + } + + public void OpenContent() + { + var parameters = new DialogParameters("id", 42); + + contentService.Navigate("Content", parameters); + contentService.Navigate("Content", typeof(DashboardView), parameters); + contentService.Navigate("Content", "status", parameters); } } ``` -### 4. 窗å£ï¼ˆDialogï¼‰ç®¡ç† +泛型与 `Type` é‡è½½ä¸è¯»å–è·¯ç”±è¡¨ã€‚åªæœ‰å­—符串é‡è½½ä»Ž Content 路由字典å–得最终类型,éšåŽé€šè¿‡æ™®é€š DI è§£æžå®žä¾‹ã€‚导航目标å¯ä»¥æ˜¯ `UserControl`ã€æ™®é€šæˆ–自定义 `ContentControl`ã€`Grid`ã€`StackPanel` ç­‰ `FrameworkElement`,但ä¸èƒ½æ˜¯ `Page` 或 `Window`。 + +## 导航通知 -#### 4.1 æ‰“å¼€çª—å£ +Page 或 Content 导航æˆåŠŸåŽï¼Œç›®æ ‡ View åŠå…¶ `DataContext` 中实现了 `INavigationAware` 的对象都会收到通知;如果二者是åŒä¸€å®žä¾‹ï¼Œåˆ™åªé€šçŸ¥ä¸€æ¬¡ã€‚ ```csharp -using SimpleNavigation.Interface; using SimpleNavigation.Common; +using SimpleNavigation.Interface; -public class MainWindowViewModel +public sealed class StatusViewModel : INavigationAware { - private readonly IDialogService dialogService; - - public MainWindowViewModel(IDialogService dialogService) - { - this.dialogService = dialogService; - } - - [RelayCommand] - public void OpenWindow() - { - // éžæ¨¡æ€æ‰“å¼€çª—å£ - var param = new DialogParameters("key", "value"); - dialogService.Show(param); - } - - [RelayCommand] - public void OpenModalDialog() + public void OnNavigated(DialogParameters? parameters) { - // æ¨¡æ€æ‰“开窗å£ï¼Œç­‰å¾…用户关闭并返回结果 - var param = new DialogParameters("key", "value"); - var result = dialogService.ShowDialog(param); - - if (result != null) - { - var returnValue = result.Get("resultKey"); - } + var id = parameters?.Get("id"); } } ``` -#### 4.2 çª—å£æŽ¥æ”¶å‚æ•°å¹¶è¿”回结果 +类库åªå‘é€é€šçŸ¥ï¼Œä¸è´Ÿè´£æŠŠ `StatusViewModel` 设置为 View çš„ `DataContext`。 + +## DialogService -让 ViewModel 或 Window 实现 `IDialogAware` 接å£ï¼š +Window åŠå…¶ä¾èµ–需è¦å…ˆæ³¨å†Œåˆ° DI: ```csharp -using SimpleNavigation.Common; -using SimpleNavigation.Interface; +services.AddTransient(); +services.AddTransient(); +``` + +使用 `IDialogService` æ˜¾ç¤ºéžæ¨¡æ€æˆ–模æ€çª—å£ï¼š + +```csharp +var input = new DialogParameters("id", 42); + +dialogService.Show(input); + +DialogParameters? result = dialogService.ShowDialog(input); +var saved = result?.Get("saved"); +``` -public class TestViewModel : IDialogAware +Window 或它的 `DataContext` å¯ä»¥å®žçް `IDialogAware` æ¥æŽ¥æ”¶å‚æ•°å’Œè¯·æ±‚关闭: + +```csharp +public sealed class TestViewModel : IDialogAware { public Action? RequestClose { get; set; } public void OnNavigated(DialogParameters? parameters) { - if (parameters != null) - { - var value = parameters.Get("key"); - // 处ç†ä¼ å…¥å‚æ•° - } + var id = parameters?.Get("id"); } - [RelayCommand] - public void Close() + public void Save() { - // 关闭窗å£å¹¶è¿”回结果 - var result = new DialogParameters("resultKey", "resultValue"); - RequestClose?.Invoke(result); + RequestClose?.Invoke(new DialogParameters("saved", true)); } } ``` -> æ¡†æž¶æ”¯æŒ `IDialogAware` åŒæ—¶å®žçŽ°åœ¨ Window 自身(code-behind)或 DataContext(ViewModel)上,两者å¯å…±å­˜ã€‚ +`DialogManager` 使用弱引用缓存åŒç±»åž‹ Window,并在 Window 关闭åŽç§»é™¤è®°å½•。 -### 5. DialogParameters 傿•°å¯¹è±¡ +## DialogParameters ```csharp -// æ–¹å¼ä¸€ï¼šé”®å€¼å¯¹æž„造 -var p1 = new DialogParameters("key", "value"); +var byKey = new DialogParameters("key", "value"); -// æ–¹å¼äºŒï¼šå­—典构造 -var p2 = new DialogParameters(new Dictionary +var byDictionary = new DialogParameters(new Dictionary { - { "id", 100 }, - { "name", "test" }, - { "data", someObject } + ["id"] = 100, + ["name"] = "test", }); -// æ–¹å¼ä¸‰ï¼šç´¢å¼•构造 -var p3 = new DialogParameters("hello", 123, DateTime.Now); -var first = p3.Get("0"); // "hello" -var second = p3.Get("1"); // 123 +var byIndex = new DialogParameters("hello", 123, DateTime.Now); +var first = byIndex.Get("0"); +var second = byIndex.Get("1"); -// èŽ·å– / è®¾ç½®å‚æ•° -var value = p1.Get("key"); -p1.Set("key", "newValue"); // å…许覆盖已有值 +byKey.Set("key", "newValue"); +var value = byKey.Get("key"); ``` -### 6. 架构设计è¦ç‚¹ +## DI 生命周期 + +`IPageService` 与 `IContentService` 注册为 singleton,并通过创建它们的 `IServiceProvider` è§£æžå¯¼èˆªç›®æ ‡ã€‚若它们由根容器创建,那么在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped æœåŠ¡æ˜¯æ— æ•ˆçš„ï¼›ä»Žæ ¹å®¹å™¨è§£æžçš„ disposable transient 会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚ + +类库ä¸ä¼šä¸ºæ¯æ¬¡å¯¼èˆªåˆ›å»ºæˆ–释放 scope,因为已显示 View çš„ç”Ÿå‘½å‘¨æœŸå±žäºŽåº”ç”¨ã€‚éœ€è¦ scoped 或 disposable View/ViewModel å¯¹è±¡å›¾çš„åº”ç”¨ï¼Œå¿…é¡»è‡ªè¡ŒæŒæœ‰åˆé€‚çš„ scopeï¼Œå¹¶åˆ¶å®šä¸Žç•Œé¢æ˜¾ç¤ºå‘¨æœŸåŒ¹é…的释放策略。 -- **DI 驱动**:所有 Page å’Œ Window å‡ç”± DI 容器解æžï¼Œæ”¯æŒæž„造函数注入 -- **WeakReference 窗å£ç¼“å­˜**:`DialogManager` 使用 `WeakReference` é˜²æ­¢å†…å­˜æ³„æ¼ -- **附加属性注册区域**:通过 `RegionService.RegionName` 在 XAML ä¸­å£°æ˜Žå¼æ³¨å†Œå¯¼èˆªåŒºåŸŸ -- **线程安全**:`PageService` 使用 `ConcurrentDictionary` 存储区域 -- **åŒç»‘定感知**ï¼šæ¡†æž¶åŒæ—¶æ£€æŸ¥ Window/Page 自身åŠå…¶ `DataContext` 是å¦å®žçŽ°äº†æ„ŸçŸ¥æŽ¥å£ -- **多目标框架**ï¼šåŒæ—¶æ”¯æŒ .NET Framework 4.8 å’Œ .NET 8.0 Windows +## 区域宿主扩展边界 -### 7. 效果预览 +`Grid`ã€`StackPanel` 等元素现在å¯ä»¥ä½œä¸º Content 导航目标,但ä¸èƒ½ä½œä¸ºåŒºåŸŸå®¿ä¸»ï¼›`TabControl` 也尚未作为区域宿主å¯ç”¨ã€‚ -![preview](https://github.com/user-attachments/assets/f1692e72-eace-44f8-a6c7-171243d2f854) +内部区域适é…器以 `FrameworkElement` 和宿主能力为边界,因此未æ¥å¯ä»¥åœ¨ resolver 中增加适é…器,而ä¸å¿…修改 `ContentService`。ä¸è¿‡ï¼Œåœ¨æ”¯æŒ `Panel` å‰å¿…é¡»æ˜Žç¡®åŒºåŸŸæ˜¯å¦æ‹¥æœ‰å…¨éƒ¨å­å…ƒç´ ä»¥åŠæ›¿æ¢/è¿½åŠ ç­–ç•¥ï¼›åœ¨æ”¯æŒ `TabControl` å‰å¿…须明确标签创建ã€é€‰æ‹©ã€å¤ç”¨å’Œ header 策略。 + +## ç ´åæ€§å‡çº§è¿ç§» + +本次é‡å»ºé‡‡ç”¨ä»¥ä¸‹ API 映射: + +```text +RegionService.RegionName -> Region.RegionName +IPageService.GetRegion(...) -> IRegionManager.GetRegion(...) +IPageService.Goback(...) -> IPageService.GoBack(...) +``` ---- +`RegionService` 已删除。`Goback` 仅作为标记了 `Obsolete` çš„è½¬å‘æ–¹æ³•ä¿ç•™ï¼ŒçŽ°æœ‰ä»£ç åº”è¿ç§»åˆ° `GoBack`。由于附加属性所有者å‘生å˜åŒ–,引用旧属性的已编译 XAML/BAML å¿…é¡»é‡æ–°æž„建。 ## License From e13cc2c945d1b389332d04a56ebb4220dfa54f1a Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 02:33:11 +0800 Subject: [PATCH 16/42] docs: complete navigation registration examples --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 939f80d..2b3a0ea 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ SimpleNavigation/ IPageService.cs # Page 导航 IContentService.cs # FrameworkElement 内容导航 INavigationAware.cs # 导航完æˆé€šçŸ¥ + IPageAware.cs # 兼容旧代ç ï¼Œç»§æ‰¿ INavigationAware IDialogService.cs # Window 显示æœåŠ¡ IDialogAware.cs # Window 导航与关闭通知 IDialogManager.cs # Window å®žä¾‹ç®¡ç† @@ -66,7 +67,9 @@ services.AddSingleton(); // 路由扩展å¯åŒæ—¶æ³¨å†Œ View/ViewModel,并å¯é€‰æ‹©æ·»åŠ å­—ç¬¦ä¸²åˆ«å。 services.AddPage("settings"); +services.AddPage(); services.AddPage("reports"); +services.AddContent("help"); services.AddContent(); services.AddContent("status"); @@ -75,7 +78,7 @@ var provider = services.BuildServiceProvider(); `AddPage` 与 `AddContent` 使用 `TryAddTransient` 注册 View å’Œå¯é€‰çš„ ViewModel,ä¸ä¼šè¦†ç›–åœ¨å®ƒä»¬ä¹‹å‰æ·»åŠ çš„æ³¨å†Œã€‚éœ€è¦è‡ªå®šä¹‰ç”Ÿå‘½å‘¨æœŸæˆ–工厂时,应先使用标准 DI 方法注册对应æœåŠ¡ã€‚è¿™äº›æ‰©å±•æ–¹æ³•ä¸ä¼šåˆ›å»ºæˆ–设置 View çš„ `DataContext`。 -åªæœ‰å¸¦ `string key` çš„é‡è½½ä¼šæ³¨å†Œè·¯ç”±åˆ«å。Page 与 Content çš„ key 空间相互独立ã€åŒºåˆ†å¤§å°å†™ï¼ŒåŒä¸€ç©ºé—´å†…ä¸èƒ½é‡å¤æ³¨å†Œ key。 +åŒæ³›åž‹æ—  key çš„é‡è½½åªæ³¨å†Œ View å’Œ ViewModelï¼›å•æ³›åž‹åŠ  key çš„é‡è½½æ³¨å†Œ View 与路由别åï¼›åŒæ³›åž‹åŠ  key çš„é‡è½½åŒæ—¶æ³¨å†Œ Viewã€ViewModel 与路由别å。Page 与 Content çš„ key 空间相互独立ã€åŒºåˆ†å¤§å°å†™ï¼ŒåŒä¸€ç©ºé—´å†…ä¸èƒ½é‡å¤æ³¨å†Œ key。 ## 声明导航区域 From e1afb62e08629903fa318f4a4b24050980bc6866 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 02:39:20 +0800 Subject: [PATCH 17/42] docs: clarify dialog and DI lifetime limits --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2b3a0ea..5780049 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SimpleNavigation 是一个基于 `Microsoft.Extensions.DependencyInjection` çš„ - `PageService`:在命åçš„ `Frame` 区域中导航 `Page`,支æŒè¿”回上一页。 - `ContentService`:在命åçš„éž `Frame` `ContentControl` 区域中显示 `UserControl`ã€è‡ªå®šä¹‰ `ContentControl` æˆ–å…¶ä»–éž `Page`ã€éž `Window` çš„ `FrameworkElement`。 -- `DialogService`:通过 DI 创建并显示 `Window`ï¼Œæ”¯æŒæ¨¡æ€/éžæ¨¡æ€çª—å£ã€å‚数传递与结果返回。 +- `DialogService`:通过 DI 创建并显示 `Window`;当å‰å¯é è·¯å¾„æ˜¯éžæ¨¡æ€æ˜¾ç¤ºã€å‚数传递与关闭请求。 - `RegionManager`ï¼šç»Ÿä¸€ç®¡ç† XAML å£°æ˜Žæˆ–ä»£ç æ³¨å†Œçš„命å区域,并以弱引用ä¿å­˜åŒºåŸŸå®¿ä¸»ã€‚ 导航目标全部由应用的 DI 容器创建。类库ä¸è®¾ç½® `DataContext`,因此 View 与 ViewModel 的构造注入和绑定方å¼ä»ç”±åº”用决定。 @@ -200,18 +200,15 @@ services.AddTransient(); services.AddTransient(); ``` -使用 `IDialogService` æ˜¾ç¤ºéžæ¨¡æ€æˆ–模æ€çª—å£ï¼š +使用 `IDialogService` æ˜¾ç¤ºéžæ¨¡æ€çª—å£ï¼š ```csharp var input = new DialogParameters("id", 42); dialogService.Show(input); - -DialogParameters? result = dialogService.ShowDialog(input); -var saved = result?.Get("saved"); ``` -Window 或它的 `DataContext` å¯ä»¥å®žçް `IDialogAware` æ¥æŽ¥æ”¶å‚æ•°å’Œè¯·æ±‚关闭: +对于 `Show`,Window 或它的 `DataContext` å¯ä»¥å®žçް `IDialogAware` æ¥æŽ¥æ”¶å‚æ•°å’Œè¯·æ±‚关闭: ```csharp public sealed class TestViewModel : IDialogAware @@ -223,13 +220,15 @@ public sealed class TestViewModel : IDialogAware var id = parameters?.Get("id"); } - public void Save() + public void Close() { - RequestClose?.Invoke(new DialogParameters("saved", true)); + RequestClose?.Invoke(null); } } ``` +`ShowDialog` 当å‰çš„关闭/结果æµç¨‹å­˜åœ¨å·²çŸ¥é™åˆ¶ï¼šå…¶ `Closing` 处ç†ä¼šå–消关闭,并å¯èƒ½åœ¨å…³é—­è¯·æ±‚䏭冿¬¡è°ƒç”¨ `Close`。在 `DialogService` 修正å‰ï¼Œä¸åº”ä¾èµ–è¯¥æ–¹æ³•å®Œæˆæ¨¡æ€å…³é—­æˆ–返回结果。 + `DialogManager` 使用弱引用缓存åŒç±»åž‹ Window,并在 Window 关闭åŽç§»é™¤è®°å½•。 ## DialogParameters @@ -253,9 +252,11 @@ var value = byKey.Get("key"); ## DI 生命周期 -`IPageService` 与 `IContentService` 注册为 singleton,并通过创建它们的 `IServiceProvider` è§£æžå¯¼èˆªç›®æ ‡ã€‚若它们由根容器创建,那么在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped æœåŠ¡æ˜¯æ— æ•ˆçš„ï¼›ä»Žæ ¹å®¹å™¨è§£æžçš„ disposable transient 会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚ +`RegisterNavigationService()` 默认把 `IPageService`ã€`IContentService`ã€`IDialogService` å’Œ `IDialogManager` 注册为 singleton。Page/Content 导航æœåŠ¡ä¸Ž `DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的解æžåˆ‡æ¢åˆ°è¯¥ scope。 + +因此,在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped Viewã€ViewModel 或 Window 对象图是无效的;从根容器解æžçš„ disposable transient 对象会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚类库ä¸ä¼šä¸ºå¯¼èˆªæˆ–窗å£åˆ›å»ºã€æŒæœ‰æˆ–释放 scope,因为已显示 UI 的生命周期属于应用。 -类库ä¸ä¼šä¸ºæ¯æ¬¡å¯¼èˆªåˆ›å»ºæˆ–释放 scope,因为已显示 View çš„ç”Ÿå‘½å‘¨æœŸå±žäºŽåº”ç”¨ã€‚éœ€è¦ scoped 或 disposable View/ViewModel å¯¹è±¡å›¾çš„åº”ç”¨ï¼Œå¿…é¡»è‡ªè¡ŒæŒæœ‰åˆé€‚çš„ scopeï¼Œå¹¶åˆ¶å®šä¸Žç•Œé¢æ˜¾ç¤ºå‘¨æœŸåŒ¹é…的释放策略。 +éœ€è¦ scoped 或 disposable View/ViewModel/Window 对象图的应用,必须在调用 `RegisterNavigationService()` å‰è¦†ç›–相关导航æœåŠ¡å’Œç®¡ç†å™¨çš„生命周期或解æžç­–略(其 `TryAdd` 注册会ä¿ç•™å…ˆå‰æ³¨å†Œï¼‰ï¼Œä»Žåº”ç”¨æŒæœ‰çš„ scope è§£æžè¿™äº›æœåŠ¡ï¼Œå¹¶è®© scope 的释放时机与对应 UI 生命周期一致。 ## 区域宿主扩展边界 From faa48375068b7c0d8d345141d1efee1e7d56e3e3 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 02:51:33 +0800 Subject: [PATCH 18/42] fix: improve content host diagnostics --- Services/ContentService.cs | 16 ++++++++++++---- .../ContentServiceTests.cs | 11 +++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Services/ContentService.cs b/Services/ContentService.cs index 46fbffc..2093b61 100644 --- a/Services/ContentService.cs +++ b/Services/ContentService.cs @@ -68,8 +68,7 @@ private void NavigateCore( var hostAdapter = RegionHostAdapterResolver.GetRequired(host); if (hostAdapter is not IContentRegionHostAdapter adapter) { - throw new InvalidOperationException( - $"Region '{regionName}' does not support content navigation."); + throw CreateInvalidHostException(regionName, host); } adapter.Present(host, content); @@ -84,8 +83,17 @@ private FrameworkElement GetRequiredHost(string regionName) return region; } - throw new InvalidOperationException( - $"Region '{regionName}' is not registered."); + throw CreateInvalidHostException(regionName, null); + } + + private static InvalidOperationException CreateInvalidHostException( + string regionName, + FrameworkElement? host) + { + var actual = host?.GetType().FullName ?? "missing"; + return new InvalidOperationException( + $"Region '{regionName}' must be a non-Frame ContentControl or another host " + + $"with a content navigation adapter but was '{actual}'."); } private static void ValidateContentType(Type targetType) diff --git a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs index d12e74e..ac45f6d 100644 --- a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs @@ -136,7 +136,11 @@ public void InvalidInputsAndUnsupportedTargetsFailFast() Assert.Throws(() => service.Navigate("frame", typeof(string))); Assert.Throws(() => service.Navigate("frame")); Assert.Throws(() => service.Navigate("frame", typeof(Window))); - Assert.Throws(() => service.Navigate("frame")); + var exception = Assert.Throws( + () => service.Navigate("frame")); + Assert.Contains("frame", exception.Message); + Assert.Contains(typeof(Frame).FullName!, exception.Message); + Assert.Contains("non-Frame ContentControl", exception.Message); GC.KeepAlive(frame); }); } @@ -153,8 +157,11 @@ public void MissingRegionUnknownKeyAndMissingDiFailFast() var host = RegisterContentHost(provider, "main"); var service = provider.GetRequiredService(); - Assert.Throws( + var missingRegionException = Assert.Throws( () => service.Navigate("missing")); + Assert.Contains("Region 'missing'", missingRegionException.Message); + Assert.Contains("was 'missing'", missingRegionException.Message); + Assert.Contains("non-Frame ContentControl", missingRegionException.Message); Assert.Throws( () => service.Navigate("main", "unknown")); var exception = Assert.Throws( From 48da10085ab912baea7f27ffbd760d865c450482 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 02:52:37 +0800 Subject: [PATCH 19/42] docs: include region manager lifetime --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5780049..db6ec93 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,9 @@ var value = byKey.Get("key"); ## DI 生命周期 -`RegisterNavigationService()` 默认把 `IPageService`ã€`IContentService`ã€`IDialogService` å’Œ `IDialogManager` 注册为 singleton。Page/Content 导航æœåŠ¡ä¸Ž `DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的解æžåˆ‡æ¢åˆ°è¯¥ scope。 +`RegisterNavigationService()` 默认把 `IRegionManager`ã€`IPageService`ã€`IContentService`ã€`IDialogService` å’Œ `IDialogManager` 注册为 singleton。`IRegionManager` ä¿å­˜å‘½ååŒºåŸŸå®¿ä¸»çš„å¼±å¼•ç”¨ï¼Œå¹¶æŒæœ‰é™æ€ `Region` 声明订阅;它ä¸è§£æž View 对象图,并会在所属 DI provider é‡Šæ”¾æ—¶å–æ¶ˆè®¢é˜…。 + +Page/Content 导航æœåŠ¡ä¸Ž `DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的目标解æžåˆ‡æ¢åˆ°è¯¥ scope。 因此,在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped Viewã€ViewModel 或 Window 对象图是无效的;从根容器解æžçš„ disposable transient 对象会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚类库ä¸ä¼šä¸ºå¯¼èˆªæˆ–窗å£åˆ›å»ºã€æŒæœ‰æˆ–释放 scope,因为已显示 UI 的生命周期属于应用。 From 537b0afb07bd252f3fd05bc9693c012e5c896b8e Mon Sep 17 00:00:00 2001 From: Junevy Date: Sun, 12 Jul 2026 21:28:01 +0800 Subject: [PATCH 20/42] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ContentControlRegionAdapter.cs | 7 +- Common/{ => Adapters}/FrameRegionAdapter.cs | 8 +- Common/Adapters/RegionHostAdapter.cs | 49 + Common/{ => Managers}/DialogManager.cs | 4 +- Common/Managers/RegionManager.cs | 274 ++++++ Common/NavigationAwareNotifier.cs | 14 +- Common/NavigationRouteRegistry.cs | 51 +- Common/RegionHostAdapter.cs | 61 -- Common/RegionManager.cs | 281 ------ Extensions/NavigationExtensions.cs | 47 +- .../Adapters/IContentRegionHostAdapter.cs | 17 + Interface/Adapters/IPageRegionHostAdapter.cs | 16 + Interface/Adapters/IRegionHostAdapter.cs | 24 + Interface/Awares/IDialogAware.cs | 22 + Interface/Awares/INavigationAware.cs | 19 + Interface/{ => Awares}/IPageAware.cs | 6 +- Interface/IContentService.cs | 22 - Interface/IDialogAware.cs | 18 - Interface/INavigationAware.cs | 8 - Interface/IPageService.cs | 27 - Interface/IRegionManager.cs | 14 - Interface/{ => Managers}/IDialogManager.cs | 5 +- Interface/Managers/IRegionManager.cs | 42 + Interface/Services/IContentService.cs | 20 + Interface/{ => Services}/IDialogService.cs | 2 +- Interface/Services/IPageService.cs | 22 + Services/ContentService.cs | 210 +++-- Services/DialogService.cs | 8 +- Services/PageService.cs | 65 +- Services/Region.cs | 880 ++++++++++-------- SimpleNavigation.csproj | 2 +- .../ContentServiceTests.cs | 4 +- .../NavigationExtensionsTests.cs | 5 +- .../PageServiceTests.cs | 3 +- .../RegionManagerTests.cs | 2 +- Tests/SimpleNavigation.Tests/RegionTests.cs | 2 +- Tests/SimpleNavigation.Tests/TestTypes.cs | 2 +- 37 files changed, 1199 insertions(+), 1064 deletions(-) rename Common/{ => Adapters}/ContentControlRegionAdapter.cs (82%) rename Common/{ => Adapters}/FrameRegionAdapter.cs (77%) create mode 100644 Common/Adapters/RegionHostAdapter.cs rename Common/{ => Managers}/DialogManager.cs (93%) create mode 100644 Common/Managers/RegionManager.cs delete mode 100644 Common/RegionHostAdapter.cs delete mode 100644 Common/RegionManager.cs create mode 100644 Interface/Adapters/IContentRegionHostAdapter.cs create mode 100644 Interface/Adapters/IPageRegionHostAdapter.cs create mode 100644 Interface/Adapters/IRegionHostAdapter.cs create mode 100644 Interface/Awares/IDialogAware.cs create mode 100644 Interface/Awares/INavigationAware.cs rename Interface/{ => Awares}/IPageAware.cs (58%) delete mode 100644 Interface/IContentService.cs delete mode 100644 Interface/IDialogAware.cs delete mode 100644 Interface/INavigationAware.cs delete mode 100644 Interface/IPageService.cs delete mode 100644 Interface/IRegionManager.cs rename Interface/{ => Managers}/IDialogManager.cs (75%) create mode 100644 Interface/Managers/IRegionManager.cs create mode 100644 Interface/Services/IContentService.cs rename Interface/{ => Services}/IDialogService.cs (94%) create mode 100644 Interface/Services/IPageService.cs diff --git a/Common/ContentControlRegionAdapter.cs b/Common/Adapters/ContentControlRegionAdapter.cs similarity index 82% rename from Common/ContentControlRegionAdapter.cs rename to Common/Adapters/ContentControlRegionAdapter.cs index 2545cbb..c632970 100644 --- a/Common/ContentControlRegionAdapter.cs +++ b/Common/Adapters/ContentControlRegionAdapter.cs @@ -1,16 +1,15 @@ +using SimpleNavigation.Interface.Adapters; using System.Windows; using System.Windows.Controls; -namespace SimpleNavigation.Common; +namespace SimpleNavigation.Common.Adapters; internal sealed class ContentControlRegionAdapter : IContentRegionHostAdapter { public RegionHostKind Kind => RegionHostKind.Content; public bool CanHandle(FrameworkElement region) - { - return region is ContentControl && region is not Frame; - } + => region is ContentControl && region is not Frame; public void Present(FrameworkElement host, FrameworkElement content) { diff --git a/Common/FrameRegionAdapter.cs b/Common/Adapters/FrameRegionAdapter.cs similarity index 77% rename from Common/FrameRegionAdapter.cs rename to Common/Adapters/FrameRegionAdapter.cs index d5ab1b8..a51f71c 100644 --- a/Common/FrameRegionAdapter.cs +++ b/Common/Adapters/FrameRegionAdapter.cs @@ -1,16 +1,14 @@ +using SimpleNavigation.Interface.Adapters; using System.Windows; using System.Windows.Controls; -namespace SimpleNavigation.Common; +namespace SimpleNavigation.Common.Adapters; internal sealed class FrameRegionAdapter : IPageRegionHostAdapter { public RegionHostKind Kind => RegionHostKind.Page; - public bool CanHandle(FrameworkElement region) - { - return region is Frame; - } + public bool CanHandle(FrameworkElement region) => region is Frame; public bool Navigate(Frame frame, Page page) { diff --git a/Common/Adapters/RegionHostAdapter.cs b/Common/Adapters/RegionHostAdapter.cs new file mode 100644 index 0000000..fdc0ee2 --- /dev/null +++ b/Common/Adapters/RegionHostAdapter.cs @@ -0,0 +1,49 @@ +using SimpleNavigation.Interface.Adapters; +using System.Windows; + +namespace SimpleNavigation.Common.Adapters; + +/// +/// ÇøÓòËÞÖ÷¿É½ÓÊܵÄÄÚÈÝÀàÐÍ +/// +internal enum RegionHostKind +{ + Page, + Content, +} + +/// +/// »ñÈ¡ÊÊÅäµ¼º½ÔªËØµÄ Adapter +/// +internal static class RegionHostAdapterResolver +{ + private static readonly IRegionHostAdapter[] HostAdapters = + { + new FrameRegionAdapter(), + new ContentControlRegionAdapter(), + }; + + /// + /// ¸ù¾Ý ¸½¼Ó¶ÔÏóµÄÀàÐÍ£¬É¸Ñ¡ÏàÆ¥ÅäµÄ Adapter + /// + /// ¸½¼Ó¶ÔÏó + /// ÏàÆ¥ÅäµÄ Adapter + /// + /// + public static IRegionHostAdapter GetRequired(FrameworkElement attachedRegion) + { + if (attachedRegion == null) + throw new ArgumentNullException(nameof(attachedRegion)); + + foreach (var adapter in HostAdapters) + { + if (adapter.CanHandle(attachedRegion)) + return adapter; + } + + var regionType = attachedRegion.GetType(); + throw new ArgumentException( + $"Region host type '{regionType.FullName}' is not supported.", + nameof(attachedRegion)); + } +} diff --git a/Common/DialogManager.cs b/Common/Managers/DialogManager.cs similarity index 93% rename from Common/DialogManager.cs rename to Common/Managers/DialogManager.cs index 697556c..863dbbd 100644 --- a/Common/DialogManager.cs +++ b/Common/Managers/DialogManager.cs @@ -1,8 +1,8 @@ using System.Windows; using Microsoft.Extensions.DependencyInjection; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Managers; -namespace SimpleNavigation.Common +namespace SimpleNavigation.Common.Managers { public class DialogManager : IDialogManager { diff --git a/Common/Managers/RegionManager.cs b/Common/Managers/RegionManager.cs new file mode 100644 index 0000000..24f97e0 --- /dev/null +++ b/Common/Managers/RegionManager.cs @@ -0,0 +1,274 @@ +using System.Windows; +using SimpleNavigation.Common.Adapters; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Services; + +namespace SimpleNavigation.Common.Managers +{ + public sealed class RegionManager : IRegionManager, IDisposable + { + private readonly Dictionary regions = new(StringComparer.Ordinal); + private readonly Action declarationSubscriber; + private readonly List pendingDeclarationChanges = new(); + private readonly object syncRoot = new(); + private bool isImportingDeclarations = true; + private bool isDisposed; + + public RegionManager() + { + declarationSubscriber = OnDeclarationChanged; + Region.Subscribe(declarationSubscriber); + + try + { + var snapshot = Region.GetActiveSnapshot(); + + lock (syncRoot) + { + foreach (var change in snapshot) + ApplyDeclarationChangeUnderLock(change); + + foreach (var change in pendingDeclarationChanges) + ApplyDeclarationChangeUnderLock(change); + + pendingDeclarationChanges.Clear(); + isImportingDeclarations = false; + } + } + catch + { + lock (syncRoot) + { + isDisposed = true; + isImportingDeclarations = false; + pendingDeclarationChanges.Clear(); + regions.Clear(); + } + + Region.Unsubscribe(declarationSubscriber); + throw; + } + } + + public void RegisterRegion(string regionName, FrameworkElement region) + { + ValidateRegionName(regionName); + if (region == null) throw new ArgumentNullException(nameof(region)); + + RegionHostAdapterResolver.GetRequired(region); // ÎÞÆ¥ÅäµÄAdapterÔòÅ׳öÒì³£ + + lock (syncRoot) + { + ThrowIfDisposedUnderLock(); + + if (TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) + { + if (ReferenceEquals(existingRegion, region)) + { + existingEntry.HasProgrammaticOwnership = true; + return; + } + + throw new InvalidOperationException($"Region '{regionName}' is already registered."); + } + + regions[regionName] = new RegionEntry(region) + { + HasProgrammaticOwnership = true, + }; + } + } + + public bool UnregisterRegion(string regionName, FrameworkElement region) + { + ValidateRegionName(regionName); + + if (region == null) + { + throw new ArgumentNullException(nameof(region)); + } + + lock (syncRoot) + { + ThrowIfDisposedUnderLock(); + + if (!TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) + { + return false; + } + + if (!ReferenceEquals(existingRegion, region) || + !existingEntry.HasProgrammaticOwnership) + { + return false; + } + + existingEntry.HasProgrammaticOwnership = false; + if (existingEntry.AttachedActivationTokens.Count == 0) + { + regions.Remove(regionName); + } + + return true; + } + } + + public FrameworkElement? GetRegion(string regionName) + { + ValidateRegionName(regionName); + + lock (syncRoot) + { + ThrowIfDisposedUnderLock(); + + if (TryGetLiveEntryUnderLock(regionName, out _, out var region)) + { + return region; + } + + return null; + } + } + + public TRegion? GetRegion(string regionName) where TRegion : FrameworkElement + => GetRegion(regionName) as TRegion; + + public void Dispose() + { + lock (syncRoot) + { + if (isDisposed) + { + return; + } + + isDisposed = true; + isImportingDeclarations = false; + pendingDeclarationChanges.Clear(); + regions.Clear(); + } + + Region.Unsubscribe(declarationSubscriber); + } + + /// + /// µ±ËÞÖ÷ÈÝÆ÷ÊôÐÔ·¢Éú±ä»¯Ê¼þ»Øµ÷ + /// + /// ±ä»¯Ê¼þЯ´øÐÅÏ¢ + private void OnDeclarationChanged(RegionDeclarationChange change) + { + lock (syncRoot) + { + if (isDisposed) + return; + + if (isImportingDeclarations) + { + pendingDeclarationChanges.Add(change); + return; + } + + ApplyDeclarationChangeUnderLock(change); + } + } + + private void ApplyDeclarationChangeUnderLock(RegionDeclarationChange change) + { + if (change.Kind == RegionDeclarationChangeKind.Add) + { + AddAttachedOwnershipUnderLock(change); + return; + } + + RemoveAttachedOwnershipUnderLock(change); + } + + private void AddAttachedOwnershipUnderLock(RegionDeclarationChange change) + { + var host = change.Host; + + if (TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost)) + { + if (!ReferenceEquals(existingHost, host)) + { + throw new InvalidOperationException($"Region '{change.Name}' is already registered."); + } + + existingEntry.AttachedActivationTokens.Add(change.ActivationToken); + return; + } + + var entry = new RegionEntry(host); + entry.AttachedActivationTokens.Add(change.ActivationToken); + regions[change.Name] = entry; + } + + private void RemoveAttachedOwnershipUnderLock(RegionDeclarationChange change) + { + if (!TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost) || + !ReferenceEquals(existingHost, change.Host)) + { + return; + } + + existingEntry.AttachedActivationTokens.Remove(change.ActivationToken); + if (!existingEntry.HasProgrammaticOwnership && + existingEntry.AttachedActivationTokens.Count == 0) + { + regions.Remove(change.Name); + } + } + + /// + /// »ñÈ¡»îÔ¾µÄ + /// + /// + /// + /// + /// + private bool TryGetLiveEntryUnderLock(string regionName, out RegionEntry entry, out FrameworkElement region) + { + if (!regions.TryGetValue(regionName, out entry!)) + { + region = null!; + return false; + } + + if (entry.Host.TryGetTarget(out region!)) + return true; + + regions.Remove(regionName); + entry = null!; + region = null!; + return false; + } + + private void ThrowIfDisposedUnderLock() + { + if (isDisposed) + throw new ObjectDisposedException(nameof(RegionManager)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null, empty, or whitespace.", nameof(regionName)); + } + } + + internal sealed class RegionEntry + { + public RegionEntry(FrameworkElement host) + { + Host = new WeakReference(host); + } + + public WeakReference Host { get; } + + public bool HasProgrammaticOwnership { get; set; } + + public HashSet AttachedActivationTokens { get; } = new(); + } +} + + diff --git a/Common/NavigationAwareNotifier.cs b/Common/NavigationAwareNotifier.cs index cbf8200..5b16577 100644 --- a/Common/NavigationAwareNotifier.cs +++ b/Common/NavigationAwareNotifier.cs @@ -1,24 +1,18 @@ -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Awares; using System.Windows; namespace SimpleNavigation.Common; internal static class NavigationAwareNotifier { - public static void Notify( - FrameworkElement target, - DialogParameters? parameters) + public static void Notify(FrameworkElement target, DialogParameters? parameters) { if (target is INavigationAware targetAware) - { targetAware.OnNavigated(parameters); - } var dataContext = target.DataContext; - if (dataContext is INavigationAware contextAware && - !ReferenceEquals(dataContext, target)) - { + + if (dataContext is INavigationAware contextAware && !ReferenceEquals(dataContext, target)) contextAware.OnNavigated(parameters); - } } } diff --git a/Common/NavigationRouteRegistry.cs b/Common/NavigationRouteRegistry.cs index 5cb86c5..6601191 100644 --- a/Common/NavigationRouteRegistry.cs +++ b/Common/NavigationRouteRegistry.cs @@ -1,28 +1,31 @@ namespace SimpleNavigation.Common { + /// + /// ±»µ¼º½¶ÔÏóµÄÀàÐÍ + /// internal enum NavigationRouteKind { Page, Content, } + /// + /// µ¼º½¶ÔÏó + /// internal sealed class NavigationRouteRegistration { - public NavigationRouteRegistration( - NavigationRouteKind kind, - string key, - Type targetType) - { - Kind = kind; - Key = key; - TargetType = targetType; - } - public NavigationRouteKind Kind { get; } public string Key { get; } public Type TargetType { get; } + + public NavigationRouteRegistration(NavigationRouteKind kind, string key, Type targetType) + { + Kind = kind; + Key = key; + TargetType = targetType; + } } internal sealed class NavigationRouteRegistry @@ -32,33 +35,33 @@ internal sealed class NavigationRouteRegistry public NavigationRouteRegistry(IEnumerable registrations) { - pages = Build(registrations, NavigationRouteKind.Page); - contents = Build(registrations, NavigationRouteKind.Content); + pages = BuildRoute(registrations, NavigationRouteKind.Page); + contents = BuildRoute(registrations, NavigationRouteKind.Content); } - public Type GetRequiredPageType(string key) => - GetRequired(pages, key, "page"); + public Type GetRequiredPageType(string key) => GetRequiredTarget(pages, key, "page"); - public Type GetRequiredContentType(string key) => - GetRequired(contents, key, "content"); + public Type GetRequiredContentType(string key) => GetRequiredTarget(contents, key, "content"); - private static IReadOnlyDictionary Build( + /// + /// Build µ¼º½Â·ÓÉ + /// + /// ±»µ¼º½¶ÔÏó + /// ±»µ¼º½¶ÔÏóµÄÀàÐÍ + /// + private IReadOnlyDictionary BuildRoute( IEnumerable registrations, NavigationRouteKind kind) { var routes = new Dictionary(StringComparer.Ordinal); + foreach (var registration in registrations.Where(item => item.Kind == kind)) - { routes.Add(registration.Key, registration.TargetType); - } return routes; } - private static Type GetRequired( - IReadOnlyDictionary routes, - string key, - string category) + private Type GetRequiredTarget(IReadOnlyDictionary routes, string key, string category) { if (string.IsNullOrWhiteSpace(key)) { @@ -68,9 +71,7 @@ private static Type GetRequired( } if (routes.TryGetValue(key, out var targetType)) - { return targetType; - } throw new KeyNotFoundException( $"No {category} route is registered for key '{key}'."); diff --git a/Common/RegionHostAdapter.cs b/Common/RegionHostAdapter.cs deleted file mode 100644 index a102c6d..0000000 --- a/Common/RegionHostAdapter.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace SimpleNavigation.Common; - -internal enum RegionHostKind -{ - Page, - Content, -} - -internal interface IRegionHostAdapter -{ - RegionHostKind Kind { get; } - - bool CanHandle(FrameworkElement region); -} - -internal interface IPageRegionHostAdapter : IRegionHostAdapter -{ - bool Navigate(Frame frame, Page page); - - bool CanGoBack(Frame frame); - - void GoBack(Frame frame); -} - -internal interface IContentRegionHostAdapter : IRegionHostAdapter -{ - void Present(FrameworkElement host, FrameworkElement content); -} - -internal static class RegionHostAdapterResolver -{ - private static readonly IRegionHostAdapter[] HostAdapters = - { - new FrameRegionAdapter(), - new ContentControlRegionAdapter(), - }; - - public static IRegionHostAdapter GetRequired(FrameworkElement region) - { - if (region == null) - { - throw new ArgumentNullException(nameof(region)); - } - - foreach (var adapter in HostAdapters) - { - if (adapter.CanHandle(region)) - { - return adapter; - } - } - - var regionType = region.GetType(); - throw new ArgumentException( - $"Region host type '{regionType.FullName}' is not supported.", - nameof(region)); - } -} diff --git a/Common/RegionManager.cs b/Common/RegionManager.cs deleted file mode 100644 index 1a91024..0000000 --- a/Common/RegionManager.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System.Windows; -using SimpleNavigation.Interface; -using SimpleNavigation.Services; - -namespace SimpleNavigation.Common; - -public sealed class RegionManager : IRegionManager, IDisposable -{ - private readonly Dictionary regions = - new(StringComparer.Ordinal); - private readonly Action declarationSubscriber; - private readonly List pendingDeclarationChanges = new(); - private readonly object syncRoot = new(); - private bool isImportingDeclarations = true; - private bool isDisposed; - - public RegionManager() - { - declarationSubscriber = OnDeclarationChanged; - Region.Subscribe(declarationSubscriber); - - try - { - var snapshot = Region.GetActiveSnapshot(); - - lock (syncRoot) - { - foreach (var change in snapshot) - { - ApplyDeclarationChangeUnderLock(change); - } - - foreach (var change in pendingDeclarationChanges) - { - ApplyDeclarationChangeUnderLock(change); - } - - pendingDeclarationChanges.Clear(); - isImportingDeclarations = false; - } - } - catch - { - lock (syncRoot) - { - isDisposed = true; - isImportingDeclarations = false; - pendingDeclarationChanges.Clear(); - regions.Clear(); - } - - Region.Unsubscribe(declarationSubscriber); - throw; - } - } - - public void RegisterRegion(string regionName, FrameworkElement region) - { - ValidateRegionName(regionName); - - if (region == null) - { - throw new ArgumentNullException(nameof(region)); - } - - RegionHostAdapterResolver.GetRequired(region); - - lock (syncRoot) - { - ThrowIfDisposedUnderLock(); - - if (TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) - { - if (ReferenceEquals(existingRegion, region)) - { - existingEntry.HasProgrammaticOwnership = true; - return; - } - - throw new InvalidOperationException($"Region '{regionName}' is already registered."); - } - - regions[regionName] = new RegionEntry(region) - { - HasProgrammaticOwnership = true, - }; - } - } - - public bool UnregisterRegion(string regionName, FrameworkElement region) - { - ValidateRegionName(regionName); - - if (region == null) - { - throw new ArgumentNullException(nameof(region)); - } - - lock (syncRoot) - { - ThrowIfDisposedUnderLock(); - - if (!TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) - { - return false; - } - - if (!ReferenceEquals(existingRegion, region) || - !existingEntry.HasProgrammaticOwnership) - { - return false; - } - - existingEntry.HasProgrammaticOwnership = false; - if (existingEntry.AttachedActivationTokens.Count == 0) - { - regions.Remove(regionName); - } - - return true; - } - } - - public FrameworkElement? GetRegion(string regionName) - { - ValidateRegionName(regionName); - - lock (syncRoot) - { - ThrowIfDisposedUnderLock(); - - if (TryGetLiveEntryUnderLock(regionName, out _, out var region)) - { - return region; - } - - return null; - } - } - - public TRegion? GetRegion(string regionName) where TRegion : FrameworkElement - { - return GetRegion(regionName) as TRegion; - } - - public void Dispose() - { - lock (syncRoot) - { - if (isDisposed) - { - return; - } - - isDisposed = true; - isImportingDeclarations = false; - pendingDeclarationChanges.Clear(); - regions.Clear(); - } - - Region.Unsubscribe(declarationSubscriber); - } - - private void OnDeclarationChanged(RegionDeclarationChange change) - { - lock (syncRoot) - { - if (isDisposed) - { - return; - } - - if (isImportingDeclarations) - { - pendingDeclarationChanges.Add(change); - return; - } - - ApplyDeclarationChangeUnderLock(change); - } - } - - private void ApplyDeclarationChangeUnderLock(RegionDeclarationChange change) - { - if (change.Kind == RegionDeclarationChangeKind.Add) - { - AddAttachedOwnershipUnderLock(change); - return; - } - - RemoveAttachedOwnershipUnderLock(change); - } - - private void AddAttachedOwnershipUnderLock(RegionDeclarationChange change) - { - var host = change.Host; - - if (TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost)) - { - if (!ReferenceEquals(existingHost, host)) - { - throw new InvalidOperationException($"Region '{change.Name}' is already registered."); - } - - existingEntry.AttachedActivationTokens.Add(change.ActivationToken); - return; - } - - var entry = new RegionEntry(host); - entry.AttachedActivationTokens.Add(change.ActivationToken); - regions[change.Name] = entry; - } - - private void RemoveAttachedOwnershipUnderLock(RegionDeclarationChange change) - { - if (!TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost) || - !ReferenceEquals(existingHost, change.Host)) - { - return; - } - - existingEntry.AttachedActivationTokens.Remove(change.ActivationToken); - if (!existingEntry.HasProgrammaticOwnership && - existingEntry.AttachedActivationTokens.Count == 0) - { - regions.Remove(change.Name); - } - } - - private bool TryGetLiveEntryUnderLock( - string regionName, - out RegionEntry entry, - out FrameworkElement region) - { - if (!regions.TryGetValue(regionName, out entry!)) - { - region = null!; - return false; - } - - if (entry.Host.TryGetTarget(out region!)) - { - return true; - } - - regions.Remove(regionName); - entry = null!; - region = null!; - return false; - } - - private void ThrowIfDisposedUnderLock() - { - if (isDisposed) - { - throw new ObjectDisposedException(nameof(RegionManager)); - } - } - - private static void ValidateRegionName(string regionName) - { - if (string.IsNullOrWhiteSpace(regionName)) - { - throw new ArgumentException("Region name cannot be null, empty, or whitespace.", nameof(regionName)); - } - } - - private sealed class RegionEntry - { - public RegionEntry(FrameworkElement host) - { - Host = new WeakReference(host); - } - - public WeakReference Host { get; } - - public bool HasProgrammaticOwnership { get; set; } - - public HashSet AttachedActivationTokens { get; } = new(); - } -} diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index 6389f69..a8dec71 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Common.Managers; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using SimpleNavigation.Services; using System.Windows; using System.Windows.Controls; @@ -25,9 +27,7 @@ public static IServiceCollection RegisterNavigationService( return serviceCollection; } - public static IServiceCollection AddPage( - this IServiceCollection services, - string key) + public static IServiceCollection AddPage(this IServiceCollection services, string key) where TPage : Page { AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); @@ -35,21 +35,16 @@ public static IServiceCollection AddPage( return services; } - public static IServiceCollection AddPage( - this IServiceCollection services) - where TPage : Page - where TViewModel : class + public static IServiceCollection AddPage(this IServiceCollection services) + where TPage : Page where TViewModel : class { services.TryAddTransient(); services.TryAddTransient(); return services; } - public static IServiceCollection AddPage( - this IServiceCollection services, - string key) - where TPage : Page - where TViewModel : class + public static IServiceCollection AddPage(this IServiceCollection services, string key) + where TPage : Page where TViewModel : class { AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); services.TryAddTransient(); @@ -57,9 +52,7 @@ public static IServiceCollection AddPage( return services; } - public static IServiceCollection AddContent( - this IServiceCollection services, - string key) + public static IServiceCollection AddContent(this IServiceCollection services,string key) where TView : FrameworkElement { ValidateContentType(typeof(TView)); @@ -68,10 +61,8 @@ public static IServiceCollection AddContent( return services; } - public static IServiceCollection AddContent( - this IServiceCollection services) - where TView : FrameworkElement - where TViewModel : class + public static IServiceCollection AddContent(this IServiceCollection services) + where TView : FrameworkElement where TViewModel : class { ValidateContentType(typeof(TView)); services.TryAddTransient(); @@ -79,11 +70,8 @@ public static IServiceCollection AddContent( return services; } - public static IServiceCollection AddContent( - this IServiceCollection services, - string key) - where TView : FrameworkElement - where TViewModel : class + public static IServiceCollection AddContent(this IServiceCollection services, string key) + where TView : FrameworkElement where TViewModel : class { ValidateContentType(typeof(TView)); AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); @@ -92,11 +80,7 @@ public static IServiceCollection AddContent( return services; } - private static void AddRoute( - IServiceCollection services, - NavigationRouteKind kind, - string key, - Type targetType) + private static void AddRoute(IServiceCollection services, NavigationRouteKind kind, string key, Type targetType) { if (string.IsNullOrWhiteSpace(key)) { @@ -118,8 +102,7 @@ descriptor.ImplementationInstance is NavigationRouteRegistration route && nameof(key)); } - services.AddSingleton( - new NavigationRouteRegistration(kind, key, targetType)); + services.AddSingleton(new NavigationRouteRegistration(kind, key, targetType)); } private static void ValidateContentType(Type targetType) diff --git a/Interface/Adapters/IContentRegionHostAdapter.cs b/Interface/Adapters/IContentRegionHostAdapter.cs new file mode 100644 index 0000000..e0f2158 --- /dev/null +++ b/Interface/Adapters/IContentRegionHostAdapter.cs @@ -0,0 +1,17 @@ +using System.Windows; + +namespace SimpleNavigation.Interface.Adapters +{ + /// + /// 容纳 对象的宿主适é…器 + /// + internal interface IContentRegionHostAdapter : IRegionHostAdapter + { + /// + /// 导航核心实现方法:将需è¦å¯¼èˆªçš„内容加入到宿主的视觉树 + /// + /// 宿主 + /// 需è¦å¯¼èˆªçš„内容 + void Present(FrameworkElement host, FrameworkElement content); + } +} diff --git a/Interface/Adapters/IPageRegionHostAdapter.cs b/Interface/Adapters/IPageRegionHostAdapter.cs new file mode 100644 index 0000000..c750074 --- /dev/null +++ b/Interface/Adapters/IPageRegionHostAdapter.cs @@ -0,0 +1,16 @@ +using System.Windows.Controls; + +namespace SimpleNavigation.Interface.Adapters +{ + /// + /// 容纳 对象的宿主适é…器 + /// + internal interface IPageRegionHostAdapter : IRegionHostAdapter + { + bool Navigate(Frame frame, Page page); + + bool CanGoBack(Frame frame); + + void GoBack(Frame frame); + } +} diff --git a/Interface/Adapters/IRegionHostAdapter.cs b/Interface/Adapters/IRegionHostAdapter.cs new file mode 100644 index 0000000..acb0507 --- /dev/null +++ b/Interface/Adapters/IRegionHostAdapter.cs @@ -0,0 +1,24 @@ +using SimpleNavigation.Common.Adapters; +using System.Windows; + +namespace SimpleNavigation.Interface.Adapters +{ + /// + /// Region 宿主适é…器 + /// è‹¥è¦å®žçŽ°å…¶ä»–ç±»åž‹æŽ§ä»¶ä½œä¸ºå®¿ä¸»çš„èƒ½åŠ›ï¼Œå¿…é¡»å®žçŽ°æ­¤æŽ¥å£ + /// + internal interface IRegionHostAdapter + { + /// + /// 宿主类型 + /// + RegionHostKind Kind { get; } + + /// + /// æ ‡å¿—å½“å‰ Adapter å¯ä»¥å¤„ç†çš„元素 + /// + /// å½“å‰ Adapter 所拥有(对应)的 Region 实例 + /// + bool CanHandle(FrameworkElement region); + } +} diff --git a/Interface/Awares/IDialogAware.cs b/Interface/Awares/IDialogAware.cs new file mode 100644 index 0000000..1aab2a2 --- /dev/null +++ b/Interface/Awares/IDialogAware.cs @@ -0,0 +1,22 @@ +using SimpleNavigation.Common; + +namespace SimpleNavigation.Interface.Awares +{ + /// + /// Window导航回调接å£ï¼Œ + /// è‹¥è¦å®žçްWindowå¯¼èˆªåŽæˆ–å…³é—­åŽæ‰§è¡Œå›žè°ƒï¼ˆä¼ é€’傿•°ï¼‰ï¼ŒView或ViewModelå¿…é¡»å®žçŽ°æ­¤æŽ¥å£ + /// + public interface IDialogAware : INavigationAware + { + /// + /// 当Dialog请求关闭时调用,å¯ä¼ é€’傿•° + /// + Action? RequestClose { get; set; } + + ///// + ///// 当Dialogå¯¼èˆªå®Œæˆæ—¶çš„回调方法 + ///// + ///// å¯¼èˆªå‚æ•° + //void OnNavigated(DialogParameters? parameters); + } +} diff --git a/Interface/Awares/INavigationAware.cs b/Interface/Awares/INavigationAware.cs new file mode 100644 index 0000000..2ecff3f --- /dev/null +++ b/Interface/Awares/INavigationAware.cs @@ -0,0 +1,19 @@ +using SimpleNavigation.Common; + +namespace SimpleNavigation.Interface.Awares +{ + /// + /// µ¼º½Íê³ÉºóµÄ»Øµ÷½Ó¿Ú£¬ + /// ÈôÐèµ¼º½ºóÖ´Ðлص÷£¨´«µÝ²ÎÊý£©£¬View »ò ViewModel±ØÐëʵÏÖ´Ë½Ó¿Ú + /// + public interface INavigationAware + { + /// + /// µ¼º½ºóµÄ»Øµ÷·½·¨ + /// + /// ´«µÝµÄ²ÎÊý + void OnNavigated(DialogParameters? parameters); + } +} + + diff --git a/Interface/IPageAware.cs b/Interface/Awares/IPageAware.cs similarity index 58% rename from Interface/IPageAware.cs rename to Interface/Awares/IPageAware.cs index 50a4729..7a062c6 100644 --- a/Interface/IPageAware.cs +++ b/Interface/Awares/IPageAware.cs @@ -1,7 +1,11 @@ using SimpleNavigation.Common; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface.Awares { + /// + /// Page导航回调接å£ï¼Œ + /// è‹¥è¦å®žçްPageå¯¼èˆªåŽæ‰§è¡Œå›žè°ƒï¼ˆä¼ é€’傿•°ï¼‰ï¼ŒView或ViewModelå¿…é¡»å®žçŽ°æ­¤æŽ¥å£ + /// public interface IPageAware : INavigationAware { /// diff --git a/Interface/IContentService.cs b/Interface/IContentService.cs deleted file mode 100644 index eaccde1..0000000 --- a/Interface/IContentService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using SimpleNavigation.Common; -using System.Windows; - -namespace SimpleNavigation.Interface; - -public interface IContentService -{ - void Navigate( - string regionName, - DialogParameters? parameters = null) - where TContent : FrameworkElement; - - void Navigate( - string regionName, - Type targetType, - DialogParameters? parameters = null); - - void Navigate( - string regionName, - string key, - DialogParameters? parameters = null); -} diff --git a/Interface/IDialogAware.cs b/Interface/IDialogAware.cs deleted file mode 100644 index 271218a..0000000 --- a/Interface/IDialogAware.cs +++ /dev/null @@ -1,18 +0,0 @@ -using SimpleNavigation.Common; - -namespace SimpleNavigation.Interface -{ - public interface IDialogAware - { - /// - /// 当Dialog请求关闭时调用 - /// - Action? RequestClose { get; set; } - - /// - /// 当Dialogå¯¼èˆªå®Œæˆæ—¶çš„回调方法 - /// - /// å¯¼èˆªå‚æ•° - void OnNavigated(DialogParameters? parameters); - } -} diff --git a/Interface/INavigationAware.cs b/Interface/INavigationAware.cs deleted file mode 100644 index fcce7d1..0000000 --- a/Interface/INavigationAware.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SimpleNavigation.Common; - -namespace SimpleNavigation.Interface; - -public interface INavigationAware -{ - void OnNavigated(DialogParameters? parameters); -} diff --git a/Interface/IPageService.cs b/Interface/IPageService.cs deleted file mode 100644 index 1b9fe13..0000000 --- a/Interface/IPageService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using SimpleNavigation.Common; -using System.Windows.Controls; - -namespace SimpleNavigation.Interface; - -public interface IPageService -{ - void Navigate( - string regionName, - DialogParameters? parameters = null) - where TPage : Page; - - void Navigate( - string regionName, - Type targetType, - DialogParameters? parameters = null); - - void Navigate( - string regionName, - string key, - DialogParameters? parameters = null); - - void GoBack(string regionName); - - [Obsolete("Use GoBack instead.")] - void Goback(string regionName); -} diff --git a/Interface/IRegionManager.cs b/Interface/IRegionManager.cs deleted file mode 100644 index ea91db9..0000000 --- a/Interface/IRegionManager.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Windows; - -namespace SimpleNavigation.Interface; - -public interface IRegionManager -{ - void RegisterRegion(string regionName, FrameworkElement region); - - bool UnregisterRegion(string regionName, FrameworkElement region); - - FrameworkElement? GetRegion(string regionName); - - TRegion? GetRegion(string regionName) where TRegion : FrameworkElement; -} diff --git a/Interface/IDialogManager.cs b/Interface/Managers/IDialogManager.cs similarity index 75% rename from Interface/IDialogManager.cs rename to Interface/Managers/IDialogManager.cs index 84fcddd..9d0801c 100644 --- a/Interface/IDialogManager.cs +++ b/Interface/Managers/IDialogManager.cs @@ -1,7 +1,10 @@ using System.Windows; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface.Managers { + /// + /// Dialog 管ç†å¯¹è±¡ + /// public interface IDialogManager { /// diff --git a/Interface/Managers/IRegionManager.cs b/Interface/Managers/IRegionManager.cs new file mode 100644 index 0000000..d8ab1c4 --- /dev/null +++ b/Interface/Managers/IRegionManager.cs @@ -0,0 +1,42 @@ +using System.Windows; + +namespace SimpleNavigation.Interface.Managers +{ + /// + /// Region¹ÜÀí¶ÔÏ󣬸ºÔðRegionµÄ×¢²á¡¢×¢Ïú¡¢»ñÈ¡ + /// + public interface IRegionManager + { + /// + /// ½«Ö¸¶¨¿Ø¼þ×¢²áΪ Region + /// + /// RegionÃû³Æ + /// Region ¶ÔÏó£¨Ö¸¶¨Îª Region µÄʵÀý¿Ø¼þ£© + void RegisterRegion(string regionName, FrameworkElement region); + + /// + /// ½«Ö¸¶¨¿Ø¼þ´Ó Region ÖÐ×¢Ïú + /// + /// Region Ãû³Æ + /// Region ¶ÔÏó£¨Ö¸¶¨Îª Region µÄʵÀý¿Ø¼þ£© + /// ÊÇ·ñ³É¹¦×¢Ïú + bool UnregisterRegion(string regionName, FrameworkElement region); + + /// + /// »ñȡָ¶¨ Region ʵÀý + /// + /// Region Ãû³Æ + /// »ñµÃµÄ Region ʵÀý + FrameworkElement? GetRegion(string regionName); + + /// + /// »ñȡָ¶¨ÀàÐ굀 Region ʵÀý + /// + /// Ö¸¶¨µÄ¿Ø¼þÀàÐÍ + /// Region Ãû³Æ + /// Ö¸¶¨ÀàÐͿؼþµÄʵÀý + TRegion? GetRegion(string regionName) where TRegion : FrameworkElement; + } +} + + diff --git a/Interface/Services/IContentService.cs b/Interface/Services/IContentService.cs new file mode 100644 index 0000000..1413fa0 --- /dev/null +++ b/Interface/Services/IContentService.cs @@ -0,0 +1,20 @@ +using SimpleNavigation.Common; +using System.Windows; + +namespace SimpleNavigation.Interface.Services +{ + /// + /// Content µ¼º½½Ó¿Ú + /// + public interface IContentService + { + void Navigate(string regionName, DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + + void Navigate(string regionName, string key, DialogParameters? parameters = null); + } +} + + diff --git a/Interface/IDialogService.cs b/Interface/Services/IDialogService.cs similarity index 94% rename from Interface/IDialogService.cs rename to Interface/Services/IDialogService.cs index 8f4beb6..c6c6b1c 100644 --- a/Interface/IDialogService.cs +++ b/Interface/Services/IDialogService.cs @@ -1,7 +1,7 @@ using SimpleNavigation.Common; using System.Windows; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface.Services { /// /// çª—å£æœåŠ¡æŽ¥å£ï¼Œç”¨äºŽæ‰“å¼€æ–°çª—å£ diff --git a/Interface/Services/IPageService.cs b/Interface/Services/IPageService.cs new file mode 100644 index 0000000..a5698d1 --- /dev/null +++ b/Interface/Services/IPageService.cs @@ -0,0 +1,22 @@ +using SimpleNavigation.Common; +using System.Windows.Controls; + +namespace SimpleNavigation.Interface.Services +{ + /// + /// Page µ¼º½½Ó¿Ú + /// + public interface IPageService + { + void Navigate(string regionName, DialogParameters? parameters = null) where TPage : Page; + + void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + + void Navigate(string regionName, string key, DialogParameters? parameters = null); + + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); + } +} \ No newline at end of file diff --git a/Services/ContentService.cs b/Services/ContentService.cs index 2093b61..4dc0000 100644 --- a/Services/ContentService.cs +++ b/Services/ContentService.cs @@ -1,125 +1,143 @@ using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Common.Adapters; +using SimpleNavigation.Interface.Adapters; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using System.Windows; using System.Windows.Controls; -namespace SimpleNavigation.Services; - -public class ContentService : IContentService +namespace SimpleNavigation.Services { - private readonly IServiceProvider provider; - private readonly IRegionManager regionManager; - private readonly NavigationRouteRegistry routes; - - public ContentService(IServiceProvider provider, IRegionManager regionManager) - { - this.provider = provider; - this.regionManager = regionManager; - routes = provider.GetRequiredService(); - } - - public void Navigate( - string regionName, - DialogParameters? parameters = null) - where TContent : FrameworkElement + public class ContentService : IContentService { - ValidateRegionName(regionName); - ValidateContentType(typeof(TContent)); - NavigateCore( - regionName, - provider.GetRequiredService(), - parameters); - } + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; - public void Navigate( - string regionName, - Type targetType, - DialogParameters? parameters = null) - { - ValidateRegionName(regionName); - ValidateContentType(targetType); - var content = provider.GetRequiredService(targetType) as FrameworkElement - ?? throw new InvalidOperationException( - $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); - NavigateCore(regionName, content, parameters); - } + public ContentService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } - public void Navigate( - string regionName, - string key, - DialogParameters? parameters = null) - { - ValidateRegionName(regionName); - var targetType = routes.GetRequiredContentType(key); - ValidateContentType(targetType); - var content = provider.GetRequiredService(targetType) as FrameworkElement - ?? throw new InvalidOperationException( - $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); - NavigateCore(regionName, content, parameters); - } + public void Navigate(string regionName, DialogParameters? parameters = null) + where TContent : FrameworkElement + { + ValidateRegionName(regionName); + ValidateContentType(typeof(TContent)); + NavigateCore(regionName, provider.GetRequiredService(), parameters); + } - private void NavigateCore( - string regionName, - FrameworkElement content, - DialogParameters? parameters) - { - var host = GetRequiredHost(regionName); - var hostAdapter = RegionHostAdapterResolver.GetRequired(host); - if (hostAdapter is not IContentRegionHostAdapter adapter) + public void Navigate(string regionName, Type targetType, DialogParameters? parameters = null) { - throw CreateInvalidHostException(regionName, host); + ValidateRegionName(regionName); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); } - adapter.Present(host, content); - NavigationAwareNotifier.Notify(content, parameters); - } + public void Navigate(string regionName, string key, DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredContentType(key); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } - private FrameworkElement GetRequiredHost(string regionName) - { - var region = regionManager.GetRegion(regionName); - if (region != null) + /// + /// µ¼º½¹¦ÄܵĺËÐÄʵÏÖ·½·¨ + /// + /// Ö¸¶¨µÄ Region Ãû³Æ + /// ÐèÒªµ¼º½µÄUI¿Ø¼þ»òÄÚÈÝ + /// ´«µÝµÄ²ÎÊý + private void NavigateCore(string regionName, FrameworkElement content, DialogParameters? parameters) { - return region; + var host = GetRequiredHost(regionName); + var hostAdapter = RegionHostAdapterResolver.GetRequired(host); + if (hostAdapter is not IContentRegionHostAdapter adapter) + throw CreateInvalidHostException(regionName, host); + + adapter.Present(host, content); + NavigationAwareNotifier.Notify(content, parameters); } - throw CreateInvalidHostException(regionName, null); - } + /// + /// »ñȡָ¶¨ Region µÄʵÀý + /// + /// Region Ãû³Æ + /// Region ʵÀý + /// ¸ÃËÞÖ÷δע²á»ò²éÕÒʧ°Ü + private FrameworkElement GetRequiredHost(string regionName) + { + var region = regionManager.GetRegion(regionName); + if (region != null) + return region; - private static InvalidOperationException CreateInvalidHostException( - string regionName, - FrameworkElement? host) - { - var actual = host?.GetType().FullName ?? "missing"; - return new InvalidOperationException( - $"Region '{regionName}' must be a non-Frame ContentControl or another host " + - $"with a content navigation adapter but was '{actual}'."); - } + throw CreateInvalidHostException(regionName, null); + } - private static void ValidateContentType(Type targetType) - { - if (targetType == null) + /// + /// ×Ô¶¨ÒåÒì³££ºËÞÖ÷δע²á»ò²éÕÒʧ°Ü + /// + /// Region Ãû³Æ + /// ËÞÖ÷ʵÀý + /// CreateInvalidHostExceptionÒì³£ + private static InvalidOperationException CreateInvalidHostException( + string regionName, + FrameworkElement? host) { - throw new ArgumentNullException(nameof(targetType)); + var actual = host?.GetType().FullName ?? "missing"; + return new InvalidOperationException( + $"Region '{regionName}' must be a non-Frame ContentControl or another host " + + $"with a content navigation adapter but was '{actual}'."); } - if (!typeof(FrameworkElement).IsAssignableFrom(targetType) || - typeof(Page).IsAssignableFrom(targetType) || - typeof(Window).IsAssignableFrom(targetType)) + /// + /// УÑéÐèÒªµ¼º½µÄ UI¿Ø¼þ»òÄÚÈÝ ÊÇ·ñ·ûºÏÔ¼Êø + /// + /// ÐèÒªµ¼º½µÄÄÚÈÝÀàÐÍ + /// + /// + private static void ValidateContentType(Type targetType) { - throw new ArgumentException( - $"Target type '{targetType.FullName}' must be a non-Page, non-Window FrameworkElement.", - nameof(targetType)); +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(targetType); +#elif NET46_OR_GREATER + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); +#endif + if (!typeof(FrameworkElement).IsAssignableFrom(targetType) + || typeof(Page).IsAssignableFrom(targetType) + || typeof(Window).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Target type '{targetType.FullName}' must be a non-Page, non-Window FrameworkElement.", + nameof(targetType)); + } } - } - private static void ValidateRegionName(string regionName) - { - if (string.IsNullOrWhiteSpace(regionName)) + /// + /// УÑé Region Ãû³Æ + /// + /// + /// + private static void ValidateRegionName(string regionName) { - throw new ArgumentException( - "Region name cannot be null or whitespace.", - nameof(regionName)); + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException( + "Region name cannot be null or whitespace.", + nameof(regionName)); + } } } } + + diff --git a/Services/DialogService.cs b/Services/DialogService.cs index c2a890f..b58c3d4 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -1,5 +1,7 @@ using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Awares; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using System.Windows; namespace SimpleNavigation.Services @@ -52,14 +54,10 @@ public void Show(DialogParameters? parameters = null) where T : Window { e.Cancel = true; if (s is IDialogAware dialogAware) - { dialogAware.RequestClose?.Invoke(result); - } if (s is Window w && w.DataContext is IDialogAware dialogVmAware) - { dialogVmAware.RequestClose?.Invoke(result); - } }; if (window.DataContext is IDialogAware vm) diff --git a/Services/PageService.cs b/Services/PageService.cs index 672e15e..e88fea6 100644 --- a/Services/PageService.cs +++ b/Services/PageService.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Common.Adapters; +using SimpleNavigation.Interface.Adapters; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using System.Windows.Controls; namespace SimpleNavigation.Services; @@ -18,22 +21,13 @@ public PageService(IServiceProvider provider, IRegionManager regionManager) routes = provider.GetRequiredService(); } - public void Navigate( - string regionName, - DialogParameters? parameters = null) - where TPage : Page + public void Navigate(string regionName, DialogParameters? parameters = null) where TPage : Page { ValidateRegionName(regionName); - NavigateCore( - regionName, - provider.GetRequiredService(), - parameters); + NavigateCore(regionName, provider.GetRequiredService(), parameters); } - public void Navigate( - string regionName, - Type targetType, - DialogParameters? parameters = null) + public void Navigate(string regionName, Type targetType, DialogParameters? parameters = null) { ValidateRegionName(regionName); ValidatePageType(targetType); @@ -43,10 +37,7 @@ public void Navigate( NavigateCore(regionName, page, parameters); } - public void Navigate( - string regionName, - string key, - DialogParameters? parameters = null) + public void Navigate(string regionName, string key, DialogParameters? parameters = null) { ValidateRegionName(regionName); var targetType = routes.GetRequiredPageType(key); @@ -62,9 +53,7 @@ public void GoBack(string regionName) var adapter = (IPageRegionHostAdapter) RegionHostAdapterResolver.GetRequired(frame); if (adapter.CanGoBack(frame)) - { adapter.GoBack(frame); - } } [Obsolete("Use GoBack instead.")] @@ -73,41 +62,52 @@ public void Goback(string regionName) GoBack(regionName); } - private void NavigateCore( - string regionName, - Page page, - DialogParameters? parameters) + /// + /// µ¼º½¹¦ÄܵĺËÐÄʵÏÖ + /// + /// Ö¸¶¨µÄ Region Ãû³Æ + /// Ö¸¶¨µÄPage + /// ´«µÝµÄ²ÎÊý + private void NavigateCore(string regionName, Page page, DialogParameters? parameters) { var frame = GetRequiredFrame(regionName); var adapter = (IPageRegionHostAdapter) RegionHostAdapterResolver.GetRequired(frame); if (adapter.Navigate(frame, page)) - { NavigationAwareNotifier.Notify(page, parameters); - } } + /// + /// »ñȡָ¶¨ Region£¨Frame£© µÄʵÀý + /// + /// Region Ãû³Æ + /// Region ʵÀý + /// Region ʵÀýÀàÐÍÒì³£ private Frame GetRequiredFrame(string regionName) { ValidateRegionName(regionName); var region = regionManager.GetRegion(regionName); if (region is Frame frame) - { return frame; - } var actual = region?.GetType().FullName ?? "missing"; throw new InvalidOperationException( $"Region '{regionName}' must be a Frame but was '{actual}'."); } + /// + /// УÑéµ¼º½µÄÄ¿±êÀàÐÍÊÇ·ñ¸ºÔðÔ¼Êø + /// + /// µ¼º½Ä¿±êµÄÀàÐÍ + /// private static void ValidatePageType(Type targetType) { +#if NET5_0_OR_GREATER + ArgumentNullException.ThrowIfNull(targetType); +#elif NET46_OR_GREATER if (targetType == null) - { throw new ArgumentNullException(nameof(targetType)); - } - +#endif if (!typeof(Page).IsAssignableFrom(targetType)) { throw new ArgumentException( @@ -116,6 +116,11 @@ private static void ValidatePageType(Type targetType) } } + /// + /// УÑé Region Ãû³ÆÊÇ·ñ¸ºÔð¹æÔò + /// + /// + /// private static void ValidateRegionName(string regionName) { if (string.IsNullOrWhiteSpace(regionName)) diff --git a/Services/Region.cs b/Services/Region.cs index 3cfe8ff..aa90fbf 100644 --- a/Services/Region.cs +++ b/Services/Region.cs @@ -1,584 +1,638 @@ +using SimpleNavigation.Common.Adapters; using System.Runtime.ExceptionServices; using System.Windows; -using SimpleNavigation.Common; -namespace SimpleNavigation.Services; - -public static class Region +namespace SimpleNavigation.Services { - private static readonly object SyncRoot = new(); - private static readonly object PublicationGate = new(); - private static readonly List Declarations = new(); - private static readonly List>> Subscribers = new(); - private static long nextActivationToken; - - public static readonly DependencyProperty RegionNameProperty = - DependencyProperty.RegisterAttached( - "RegionName", - typeof(string), - typeof(Region), - new PropertyMetadata(null, OnRegionNameChanged)); - - public static string? GetRegionName(DependencyObject obj) + public static class Region { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } + private static readonly object SyncRoot = new(); + private static readonly object PublicationGate = new(); + private static readonly List Declarations = new(); + private static readonly List>> Subscribers = new(); + private static long nextActivationToken; - return (string?)obj.GetValue(RegionNameProperty); - } + #region Attached property + public static readonly DependencyProperty RegionNameProperty = + DependencyProperty.RegisterAttached( + "RegionName", + typeof(string), + typeof(Region), + new PropertyMetadata(null, OnRegionNameChanged)); - public static void SetRegionName(DependencyObject obj, string value) - { - if (obj == null) + public static string? GetRegionName(DependencyObject obj) { - throw new ArgumentNullException(nameof(obj)); + if (obj == null) + throw new ArgumentNullException(nameof(obj)); + + return (string?)obj.GetValue(RegionNameProperty); } - ValidateRegionName(value); - var host = GetRequiredFrameworkElement(obj); - RegionHostAdapterResolver.GetRequired(host); - ValidateAttachedNameAvailability(host, value); + public static void SetRegionName(DependencyObject obj, string value) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); - obj.SetValue(RegionNameProperty, value); - } + // Before set value to confirm: + // 1) The region name is valid; + ValidateRegionName(value); + // 2) The container is valid (container must be FrameworkElement); + var host = GetRequiredFrameworkElement(obj); + // 3) The adapter has been existed (the container has been have a matching adapter); + RegionHostAdapterResolver.GetRequired(host); + // 4) The region name and container is valid; + ValidateAttachedNameAvailability(host, value); - internal static void Subscribe(Action subscriber) - { - if (subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); + obj.SetValue(RegionNameProperty, value); } - lock (SyncRoot) + /// + /// ¸½¼ÓÊôÐÔ RegionName ×¢²á»Øµ÷ + /// + /// ±»¸½¼ÓµÄËÞÖ÷ÈÝÆ÷ + /// ʼþÐÅÏ¢ + private static void OnRegionNameChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) { - PruneDeadSubscribersUnderLock(); - Subscribers.Add(new WeakReference>(subscriber)); + if (eventArgs.NewValue == null) + { + ClearDeclaration(dependencyObject); // Region nameΪ¿Õ£¬ÒƳý¸ÃËÞÖ÷ÈÝÆ÷ + return; + } + + var regionName = (string)eventArgs.NewValue; + try + { + ValidateRegionName(regionName); + var host = GetRequiredFrameworkElement(dependencyObject); + RegionHostAdapterResolver.GetRequired(host); // »ñȡƥÅäµÄ Adapter + ApplyRegionName(host, regionName); + } + catch (Exception exception) + { + var originalFailure = ExceptionDispatchInfo.Capture(exception); + + if (!HasMatchingDeclaration(dependencyObject, regionName)) + { + try + { + RestoreDependencyPropertyValue(dependencyObject, eventArgs.OldValue); // »Ö¸´·¢ÉúÒ쳣ǰµÄ Region name + } + catch + { + // Preserve the validation or registration failure that caused the rollback. + } + } + + originalFailure.Throw(); + throw; + } } - } + #endregion - internal static void Unsubscribe(Action subscriber) - { - if (subscriber == null) + /// + /// ×¢²á»Øµ÷·½·¨ + /// + /// + /// + internal static void Subscribe(Action subscriber) { - return; + if (subscriber == null) + throw new ArgumentNullException(nameof(subscriber)); + + lock (SyncRoot) + { + RemoveDeadSubscribersUnderLock(); + Subscribers.Add(new WeakReference>(subscriber)); + } } - lock (SyncRoot) + internal static void Unsubscribe(Action subscriber) { - for (var index = Subscribers.Count - 1; index >= 0; index--) + if (subscriber == null) + return; + + lock (SyncRoot) { - if (!Subscribers[index].TryGetTarget(out var existingSubscriber) || - existingSubscriber.Equals(subscriber)) + for (var index = Subscribers.Count - 1; index >= 0; index--) { - Subscribers.RemoveAt(index); + if (!Subscribers[index].TryGetTarget(out var existingSubscriber) || + existingSubscriber.Equals(subscriber)) + { + Subscribers.RemoveAt(index); + } } } } - } - internal static IReadOnlyList GetActiveSnapshot() - { - lock (SyncRoot) + /// + /// »ñÈ¡ËùÓÐδȡÏû×¢²áµÄËÞÖ÷ÈÝÆ÷ + /// + /// + internal static IReadOnlyList GetActiveSnapshot() { - PruneDeadDeclarationsUnderLock(); - - var snapshot = new List(); - foreach (var declaration in Declarations) + lock (SyncRoot) { - if (declaration.IsActive && declaration.Host.TryGetTarget(out var host)) + RemoveDeadDeclarationsUnderLock(); + + var snapshot = new List(); + foreach (var declaration in Declarations) { - snapshot.Add(CreateChange( - declaration, - host, - RegionDeclarationChangeKind.Add)); + if (declaration.IsActive && declaration.Host.TryGetTarget(out var host)) + { + snapshot.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Add)); + } } - } - return snapshot; + return snapshot; + } } - } - private static void OnRegionNameChanged( - DependencyObject dependencyObject, - DependencyPropertyChangedEventArgs eventArgs) - { - if (eventArgs.NewValue == null) + /// + /// Ìí¼ÓËÞÖ÷ÈÝÆ÷£¬²¢Ö¸¶¨ Region name + /// + /// ×¢²áµÄËÞÖ÷ÈÝÆ÷ + /// Region name + private static void ApplyRegionName(FrameworkElement host, string regionName) { - ClearDeclaration(dependencyObject); - return; + lock (PublicationGate) + { + ApplyRegionNameUnderPublicationGate(host, regionName); + } } - var regionName = (string)eventArgs.NewValue; - - try + private static void ApplyRegionNameUnderPublicationGate(FrameworkElement host, string regionName) { - ValidateRegionName(regionName); - var host = GetRequiredFrameworkElement(dependencyObject); - RegionHostAdapterResolver.GetRequired(host); - ApplyRegionName(host, regionName); - } - catch (Exception exception) - { - var originalFailure = ExceptionDispatchInfo.Capture(exception); + Declaration? createdDeclaration = null; + List changes; + Action[] subscribers; + + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + ValidateAttachedNameAvailabilityUnderLock(host, regionName); // УÑéRegion nameÊÇ·ñºÏ·¨ Óë ËÞÖ÷ÈÝÆ÷ÊÇ·ñÖØ¸´×¢²á + + var declaration = FindDeclarationUnderLock(host); // ²éÕÒËÞÖ÷ÈÝÆ÷ʵÀýÊÇ·ñ´æÔÚ + + // ËÞÖ÷ÈÝÆ÷ÒÑ×¢²á£¬ÇÒÆäRegion nameÓë Ԥע²áµÄRegion nameÏàͬ£¬²»Ö´ÐÐÈκβÙ×÷ + if (declaration != null && string.Equals(declaration.Name, regionName, StringComparison.Ordinal)) + return; + + changes = new List(2); + + if (declaration == null) // ËÞÖ÷ÈÝÆ÷δע²á + { + // ´´½¨ÐµÄËÞÖ÷ÈÝÆ÷ + declaration = new Declaration( + new WeakReference(host), + regionName, + isActive: true, + GetNextActivationTokenUnderLock()); + Declarations.Add(declaration); + createdDeclaration = declaration; + } + else // ËÞÖ÷ÈÝÆ÷ÒÑ´æÔÚ£¬¸üÐÂËÞÖ÷ÈÝÆ÷ÐÅÏ¢ + { + if (declaration.IsActive) + { + changes.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Remove)); + } + + declaration.Name = regionName; //¸üРRegion name + declaration.IsActive = true; + declaration.ActivationToken = GetNextActivationTokenUnderLock(); + } + + changes.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Add)); + subscribers = GetLiveSubscribersUnderLock(); + } - if (!HasMatchingDeclaration(dependencyObject, regionName)) + if (createdDeclaration != null) { try { - RestoreDependencyPropertyValue(dependencyObject, eventArgs.OldValue); + AttachLifecycleHandlers(host); // ¶©ÔÄLoaded ºÍ UnLoaded ʼþ } catch { - // Preserve the validation or registration failure that caused the rollback. + DetachLifecycleHandlers(host); // È¡Ïû¶©ÔÄLoaded ºÍ UnLoaded ʼþ + + lock (SyncRoot) + { + Declarations.Remove(createdDeclaration); // ÒÆ³ýËÞÖ÷ÈÝÆ÷ + } + + throw; } } - originalFailure.Throw(); - throw; + PublishChanges(changes, subscribers); } - } - private static void ApplyRegionName(FrameworkElement host, string regionName) - { - lock (PublicationGate) + /// + /// Remove Ö¸¶¨µÄËÞÖ÷ÈÝÆ÷ + /// + /// + private static void ClearDeclaration(DependencyObject dependencyObject) { - ApplyRegionNameUnderPublicationGate(host, regionName); - } - } - - private static void ApplyRegionNameUnderPublicationGate( - FrameworkElement host, - string regionName) - { - Declaration? createdDeclaration = null; - List changes; - Action[] subscribers; - - lock (SyncRoot) - { - PruneDeadDeclarationsUnderLock(); - ValidateAttachedNameAvailabilityUnderLock(host, regionName); + if (dependencyObject is not FrameworkElement host) + return; - var declaration = FindDeclarationUnderLock(host); - if (declaration != null && - string.Equals(declaration.Name, regionName, StringComparison.Ordinal)) + lock (PublicationGate) { - return; + ClearDeclarationUnderPublicationGate(host); } + } - changes = new List(2); + private static void ClearDeclarationUnderPublicationGate(FrameworkElement host) + { + Declaration? removedDeclaration; + List changes; + Action[] subscribers; - if (declaration == null) - { - declaration = new Declaration( - new WeakReference(host), - regionName, - isActive: true, - GetNextActivationTokenUnderLock()); - Declarations.Add(declaration); - createdDeclaration = declaration; - } - else + lock (SyncRoot) { - if (declaration.IsActive) + RemoveDeadDeclarationsUnderLock(); + removedDeclaration = FindDeclarationUnderLock(host); + if (removedDeclaration == null) + { + return; + } + + changes = new List(1); + if (removedDeclaration.IsActive) { changes.Add(CreateChange( - declaration, + removedDeclaration, host, RegionDeclarationChangeKind.Remove)); } - declaration.Name = regionName; - declaration.IsActive = true; - declaration.ActivationToken = GetNextActivationTokenUnderLock(); + Declarations.Remove(removedDeclaration); + subscribers = GetLiveSubscribersUnderLock(); } - changes.Add(CreateChange( - declaration, - host, - RegionDeclarationChangeKind.Add)); - subscribers = GetLiveSubscribersUnderLock(); + DetachLifecycleHandlers(host); + PublishChanges(changes, subscribers); } - if (createdDeclaration != null) + #region ËÞÖ÷ÈÝÆ÷Loaded ºÍ UnLoaded ʼþ + private static void OnHostLoaded(object sender, RoutedEventArgs eventArgs) { - try + if (sender is not FrameworkElement host) + return; + + lock (PublicationGate) { - AttachLifecycleHandlers(host); + ActivateHostUnderPublicationGate(host); } - catch - { - DetachLifecycleHandlers(host); + } - lock (SyncRoot) + private static void ActivateHostUnderPublicationGate(FrameworkElement host) + { + RegionDeclarationChange? change = null; + Action[] subscribers = Array.Empty>(); + + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + var declaration = FindDeclarationUnderLock(host); + if (declaration == null || declaration.IsActive) { - Declarations.Remove(createdDeclaration); + return; } - throw; + declaration.IsActive = true; + declaration.ActivationToken = GetNextActivationTokenUnderLock(); + change = CreateChange(declaration, host, RegionDeclarationChangeKind.Add); + subscribers = GetLiveSubscribersUnderLock(); } - } - PublishChanges(changes, subscribers); - } - - private static void ClearDeclaration(DependencyObject dependencyObject) - { - if (dependencyObject is not FrameworkElement host) - { - return; + PublishChanges(new[] { change }, subscribers); } - lock (PublicationGate) + private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) { - ClearDeclarationUnderPublicationGate(host); - } - } - - private static void ClearDeclarationUnderPublicationGate(FrameworkElement host) - { - Declaration? removedDeclaration; - List changes; - Action[] subscribers; + if (sender is not FrameworkElement host) + return; - lock (SyncRoot) - { - PruneDeadDeclarationsUnderLock(); - removedDeclaration = FindDeclarationUnderLock(host); - if (removedDeclaration == null) + lock (PublicationGate) { - return; + DeactivateHostUnderPublicationGate(host); } + } + + private static void DeactivateHostUnderPublicationGate(FrameworkElement host) + { + RegionDeclarationChange? change = null; + Action[] subscribers = Array.Empty>(); - changes = new List(1); - if (removedDeclaration.IsActive) + lock (SyncRoot) { - changes.Add(CreateChange( - removedDeclaration, - host, - RegionDeclarationChangeKind.Remove)); + RemoveDeadDeclarationsUnderLock(); + var declaration = FindDeclarationUnderLock(host); + if (declaration == null || !declaration.IsActive) // ÈÝÆ÷ÒÑʧЧ²¢±»ÒƳý »ò ÈÝÆ÷²»´æÔÚ + return; + + declaration.IsActive = false; + change = CreateChange(declaration, host, RegionDeclarationChangeKind.Remove); // ´´½¨ÒƳýÈÝÆ÷ʼþ + subscribers = GetLiveSubscribersUnderLock(); } - Declarations.Remove(removedDeclaration); - subscribers = GetLiveSubscribersUnderLock(); + PublishChanges(new[] { change }, subscribers); // ·¢²¼Ê¼þ } + #endregion - DetachLifecycleHandlers(host); - PublishChanges(changes, subscribers); - } - - private static void OnHostLoaded(object sender, RoutedEventArgs eventArgs) - { - if (sender is not FrameworkElement host) + /// + /// ¶©ÔÄËÞÖ÷ÈÝÆ÷Loaded ºÍ UnLoaded ʼþ + /// + /// + private static void AttachLifecycleHandlers(FrameworkElement host) { - return; + host.Loaded += OnHostLoaded; + host.Unloaded += OnHostUnloaded; } - lock (PublicationGate) + /// + /// È¡Ïû¶©ÔÄËÞÖ÷ÈÝÆ÷Loaded ºÍ UnLoaded ʼþ + /// + /// + private static void DetachLifecycleHandlers(FrameworkElement host) { - ActivateHostUnderPublicationGate(host); + host.Loaded -= OnHostLoaded; + host.Unloaded -= OnHostUnloaded; } - } - - private static void ActivateHostUnderPublicationGate(FrameworkElement host) - { - RegionDeclarationChange? change = null; - Action[] subscribers = Array.Empty>(); - lock (SyncRoot) + private static void ValidateAttachedNameAvailability(FrameworkElement host, string regionName) { - PruneDeadDeclarationsUnderLock(); - var declaration = FindDeclarationUnderLock(host); - if (declaration == null || declaration.IsActive) + lock (SyncRoot) { - return; + RemoveDeadDeclarationsUnderLock(); + ValidateAttachedNameAvailabilityUnderLock(host, regionName); } - - declaration.IsActive = true; - declaration.ActivationToken = GetNextActivationTokenUnderLock(); - change = CreateChange(declaration, host, RegionDeclarationChangeKind.Add); - subscribers = GetLiveSubscribersUnderLock(); } - PublishChanges(new[] { change }, subscribers); - } - - private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) - { - if (sender is not FrameworkElement host) + /// + /// ²éѯÊÇ·ñ´æÔÚ·ûºÏÌõ¼þµÄËÞÖ÷ÈÝÆ÷ + /// + /// ËÞÖ÷ÈÝÆ÷¶ÔÏó + /// Region name + /// ÊÇ·ñ´æÔÚ·ûºÏÌõ¼þµÄËÞÖ÷ÈÝÆ÷ + private static bool HasMatchingDeclaration(DependencyObject dependencyObject, string regionName) { - return; - } + if (dependencyObject is not FrameworkElement host) + return false; - lock (PublicationGate) - { - DeactivateHostUnderPublicationGate(host); + lock (SyncRoot) + { + var declaration = FindDeclarationUnderLock(host); + return declaration != null && + string.Equals(declaration.Name, regionName, StringComparison.Ordinal); + } } - } - private static void DeactivateHostUnderPublicationGate(FrameworkElement host) - { - RegionDeclarationChange? change = null; - Action[] subscribers = Array.Empty>(); - - lock (SyncRoot) + /// + /// ÔÚ×¢²á Region name ·¢ÉúÒ쳣ʱ£¬»Ø¹ö Region name + /// + /// Ô¤±»×¢²áµÄËÞÖ÷ÈÝÆ÷ + /// ·¢ÉúÒ쳣ǰµÄRegion name + private static void RestoreDependencyPropertyValue(DependencyObject dependencyObject, object? oldValue) { - PruneDeadDeclarationsUnderLock(); - var declaration = FindDeclarationUnderLock(host); - if (declaration == null || !declaration.IsActive) + if (oldValue == null || ReferenceEquals(oldValue, DependencyProperty.UnsetValue)) { + dependencyObject.ClearValue(RegionNameProperty); return; } - declaration.IsActive = false; - change = CreateChange(declaration, host, RegionDeclarationChangeKind.Remove); - subscribers = GetLiveSubscribersUnderLock(); + dependencyObject.SetValue(RegionNameProperty, oldValue); } - PublishChanges(new[] { change }, subscribers); - } - - private static void AttachLifecycleHandlers(FrameworkElement host) - { - host.Loaded += OnHostLoaded; - host.Unloaded += OnHostUnloaded; - } - - private static void DetachLifecycleHandlers(FrameworkElement host) - { - host.Loaded -= OnHostLoaded; - host.Unloaded -= OnHostUnloaded; - } - - private static void ValidateAttachedNameAvailability( - FrameworkElement host, - string regionName) - { - lock (SyncRoot) + /// + /// ¼ì²é Region name Óë Ԥע²áµÄËÞÖ÷ÈÝÆ÷ÊÇ·ñÖØ¸´×¢²á + /// + /// ËÞÖ÷ÈÝÆ÷ + /// Region name + /// ËÞÖ÷ÈÝÆ÷ÖØ¸´×¢²á»òRegion nameÒÑ´æÔÚ + private static void ValidateAttachedNameAvailabilityUnderLock(FrameworkElement host, string regionName) { - PruneDeadDeclarationsUnderLock(); - ValidateAttachedNameAvailabilityUnderLock(host, regionName); - } - } + foreach (var declaration in Declarations) + { + if (!string.Equals(declaration.Name, regionName, StringComparison.Ordinal) + || !declaration.Host.TryGetTarget(out var existingHost) + || ReferenceEquals(existingHost, host)) + { + continue; + } - private static bool HasMatchingDeclaration( - DependencyObject dependencyObject, - string regionName) - { - if (dependencyObject is not FrameworkElement host) - { - return false; + throw new InvalidOperationException( + $"Region '{regionName}' is already declared on another host."); + } } - lock (SyncRoot) + /// + /// ½«¸½¼Ó¶ÔÏóת»»Îª FrameworkElement + /// + /// ÐèҪת»»µÄ¸½¼Ó¶ÔÏó + /// תΪΪ FrameworkElement µÄ¸½¼Ó¶ÔÏó + /// ת»»Ê§°ÜÅ׳öÒì³£ + private static FrameworkElement GetRequiredFrameworkElement(DependencyObject obj) { - var declaration = FindDeclarationUnderLock(host); - return declaration != null && - string.Equals(declaration.Name, regionName, StringComparison.Ordinal); + if (obj is FrameworkElement host) + return host; + + throw new ArgumentException( + $"Region host type '{obj.GetType().FullName}' is not a FrameworkElement.", + nameof(obj)); } - } - private static void RestoreDependencyPropertyValue( - DependencyObject dependencyObject, - object? oldValue) - { - if (oldValue == null || ReferenceEquals(oldValue, DependencyProperty.UnsetValue)) + /// + /// УÑé Region name ÊÇ·ñºÏ·¨ + /// + private static void ValidateRegionName(string regionName) { - dependencyObject.ClearValue(RegionNameProperty); - return; + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException( + "Region name cannot be null, empty, or whitespace.", + nameof(regionName)); + } } - dependencyObject.SetValue(RegionNameProperty, oldValue); - } - - private static void ValidateAttachedNameAvailabilityUnderLock( - FrameworkElement host, - string regionName) - { - foreach (var declaration in Declarations) + /// + /// Ñ­»·²éÕÒµ±Ç°ËÞÖ÷ÈÝÆ÷ÊÇ·ñÒѾ­´æÔÚ²¢·µ»Ø + /// µ±Ç°³ÌÐò¼¯ÖдæÔÚ Content ËÞÖ÷ÈÝÆ÷¼¯ ºÍ PageËÞÖ÷ÈÝÆ÷¼¯£¬ËûÃÇ»¥Ïà¶ÀÁ¢£¬ÇÒRegion name ¿ÉÏàͬ + /// + /// Ô¤±»×¢²áΪËÞÖ÷ÈÝÆ÷µÄ¿Ø¼þ£¨ÔªËØ£© + /// ·µ»ØÒѱ»×¢²áΪËÞÖ÷ÈÝÆ÷µÄ¿Ø¼þ£¬Èç¹ûδ±»×¢²á£¬Ôò·µ»Ø null + private static Declaration? FindDeclarationUnderLock(FrameworkElement host) { - if (!string.Equals(declaration.Name, regionName, StringComparison.Ordinal) || - !declaration.Host.TryGetTarget(out var existingHost) || - ReferenceEquals(existingHost, host)) + foreach (var declaration in Declarations) { - continue; + if (declaration.Host.TryGetTarget(out var existingHost) && ReferenceEquals(existingHost, host)) + return declaration; } - throw new InvalidOperationException( - $"Region '{regionName}' is already declared on another host."); + return null; } - } - private static FrameworkElement GetRequiredFrameworkElement(DependencyObject obj) - { - if (obj is FrameworkElement host) + /// + /// ÒÆ³ýʧЧµÄËÞÖ÷ÈÝÆ÷ + /// + private static void RemoveDeadDeclarationsUnderLock() { - return host; + for (var index = Declarations.Count - 1; index >= 0; index--) + { + if (!Declarations[index].Host.TryGetTarget(out _)) + { + Declarations.RemoveAt(index); + } + } } - throw new ArgumentException( - $"Region host type '{obj.GetType().FullName}' is not a FrameworkElement.", - nameof(obj)); - } - - private static void ValidateRegionName(string regionName) - { - if (string.IsNullOrWhiteSpace(regionName)) + /// + /// ÒÆ³ýʧЧµÄ¶©ÔÄÕß + /// + private static void RemoveDeadSubscribersUnderLock() { - throw new ArgumentException( - "Region name cannot be null, empty, or whitespace.", - nameof(regionName)); + for (var index = Subscribers.Count - 1; index >= 0; index--) + { + if (!Subscribers[index].TryGetTarget(out _)) + { + Subscribers.RemoveAt(index); + } + } } - } - private static Declaration? FindDeclarationUnderLock(FrameworkElement host) - { - foreach (var declaration in Declarations) + /// + /// »ñÈ¡»îÔ¾£¨Î´È¡Ïû×¢²á£©µÄ¶©ÔÄÕß + /// + /// + private static Action[] GetLiveSubscribersUnderLock() { - if (declaration.Host.TryGetTarget(out var existingHost) && - ReferenceEquals(existingHost, host)) + var liveSubscribers = new List>(Subscribers.Count); + + for (var index = 0; index < Subscribers.Count;) { - return declaration; + if (Subscribers[index].TryGetTarget(out var subscriber)) + { + liveSubscribers.Add(subscriber); + index++; + } + else + { + Subscribers.RemoveAt(index); + } } + + return liveSubscribers.ToArray(); } - return null; - } + /// + /// Éú³ÉÏÂÒ»¸öËÞÖ÷ÈÝÆ÷µÄ Token£¨Id£© + /// + /// ËÞÖ÷ÈÝÆ÷Id + private static long GetNextActivationTokenUnderLock() => ++nextActivationToken; - private static void PruneDeadDeclarationsUnderLock() - { - for (var index = Declarations.Count - 1; index >= 0; index--) + private static RegionDeclarationChange CreateChange( + Declaration declaration, + FrameworkElement host, + RegionDeclarationChangeKind kind) { - if (!Declarations[index].Host.TryGetTarget(out _)) - { - Declarations.RemoveAt(index); - } + return new RegionDeclarationChange( + declaration.Name, + host, + declaration.ActivationToken, + kind); } - } - private static void PruneDeadSubscribersUnderLock() - { - for (var index = Subscribers.Count - 1; index >= 0; index--) + private static void PublishChanges( + IEnumerable changes, + IReadOnlyList> subscribers) { - if (!Subscribers[index].TryGetTarget(out _)) + ExceptionDispatchInfo? firstFailure = null; + + foreach (var change in changes) { - Subscribers.RemoveAt(index); + foreach (var subscriber in subscribers) + { + try + { + subscriber(change); + } + catch (Exception exception) + { + firstFailure ??= ExceptionDispatchInfo.Capture(exception); + } + } } - } - } - private static Action[] GetLiveSubscribersUnderLock() - { - var liveSubscribers = new List>(Subscribers.Count); + firstFailure?.Throw(); + } - for (var index = 0; index < Subscribers.Count;) + /// + /// ËÞÖ÷ÈÝÆ÷¶ÔÏó + /// + private sealed class Declaration { - if (Subscribers[index].TryGetTarget(out var subscriber)) + public Declaration(WeakReference host, string name, bool isActive, long activationToken) { - liveSubscribers.Add(subscriber); - index++; + Host = host; + Name = name; + IsActive = isActive; + ActivationToken = activationToken; } - else - { - Subscribers.RemoveAt(index); - } - } - return liveSubscribers.ToArray(); - } + public WeakReference Host { get; } - private static long GetNextActivationTokenUnderLock() - { - return ++nextActivationToken; - } + public string Name { get; set; } - private static RegionDeclarationChange CreateChange( - Declaration declaration, - FrameworkElement host, - RegionDeclarationChangeKind kind) - { - return new RegionDeclarationChange( - declaration.Name, - host, - declaration.ActivationToken, - kind); - } + public bool IsActive { get; set; } - private static void PublishChanges( - IEnumerable changes, - IReadOnlyList> subscribers) - { - ExceptionDispatchInfo? firstFailure = null; - - foreach (var change in changes) - { - foreach (var subscriber in subscribers) - { - try - { - subscriber(change); - } - catch (Exception exception) - { - firstFailure ??= ExceptionDispatchInfo.Capture(exception); - } - } + public long ActivationToken { get; set; } } + } - firstFailure?.Throw(); + /// + /// Region ·¢Éú±ä»¯Ê±µÄÀàÐÍ + /// + internal enum RegionDeclarationChangeKind + { + Add, + Remove, } - private sealed class Declaration + /// + /// Region·¢Éú±ä»¯Ê±µÄ¶ÔÏó + /// + internal sealed class RegionDeclarationChange { - public Declaration( - WeakReference host, + public RegionDeclarationChange( string name, - bool isActive, - long activationToken) + FrameworkElement host, + long activationToken, + RegionDeclarationChangeKind kind) { - Host = host; Name = name; - IsActive = isActive; + Host = host; ActivationToken = activationToken; + Kind = kind; } - public WeakReference Host { get; } + public string Name { get; } - public string Name { get; set; } + public FrameworkElement Host { get; } - public bool IsActive { get; set; } + public long ActivationToken { get; } - public long ActivationToken { get; set; } + public RegionDeclarationChangeKind Kind { get; } } } -internal enum RegionDeclarationChangeKind -{ - Add, - Remove, -} - -internal sealed class RegionDeclarationChange -{ - public RegionDeclarationChange( - string name, - FrameworkElement host, - long activationToken, - RegionDeclarationChangeKind kind) - { - Name = name; - Host = host; - ActivationToken = activationToken; - Kind = kind; - } - - public string Name { get; } - public FrameworkElement Host { get; } - - public long ActivationToken { get; } - - public RegionDeclarationChangeKind Kind { get; } -} diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index 29ade99..b856f7b 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,7 +8,7 @@ 13 true Junevy.SimpleNavigation - 1.0.1 + 2.0.0 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. $(DefaultItemExcludesInProjectFolder);Tests/** diff --git a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs index ac45f6d..6b26ac4 100644 --- a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; using SimpleNavigation.Extensions; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Awares; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using SimpleNavigation.Tests.TestInfrastructure; using System.Windows; using System.Windows.Controls; diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index f8344f9..3d5a8b6 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -1,7 +1,8 @@ using Microsoft.Extensions.DependencyInjection; -using SimpleNavigation.Common; +using SimpleNavigation.Common.Managers; using SimpleNavigation.Extensions; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using SimpleNavigation.Services; using SimpleNavigation.Tests.TestInfrastructure; using System.Reflection; diff --git a/Tests/SimpleNavigation.Tests/PageServiceTests.cs b/Tests/SimpleNavigation.Tests/PageServiceTests.cs index 7fd810d..d0e943b 100644 --- a/Tests/SimpleNavigation.Tests/PageServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/PageServiceTests.cs @@ -1,7 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; using SimpleNavigation.Extensions; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using SimpleNavigation.Tests.TestInfrastructure; using System.Windows.Controls; using System.Windows.Navigation; diff --git a/Tests/SimpleNavigation.Tests/RegionManagerTests.cs b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs index f91cfab..7423082 100644 --- a/Tests/SimpleNavigation.Tests/RegionManagerTests.cs +++ b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Windows; using System.Windows.Controls; -using SimpleNavigation.Common; +using SimpleNavigation.Common.Managers; using SimpleNavigation.Tests.TestInfrastructure; namespace SimpleNavigation.Tests; diff --git a/Tests/SimpleNavigation.Tests/RegionTests.cs b/Tests/SimpleNavigation.Tests/RegionTests.cs index fbe6b82..09fb76b 100644 --- a/Tests/SimpleNavigation.Tests/RegionTests.cs +++ b/Tests/SimpleNavigation.Tests/RegionTests.cs @@ -2,9 +2,9 @@ using System.Reflection; using System.Windows; using System.Windows.Controls; -using SimpleNavigation.Common; using SimpleNavigation.Services; using SimpleNavigation.Tests.TestInfrastructure; +using SimpleNavigation.Common.Managers; namespace SimpleNavigation.Tests; diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index ce81e81..0765008 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -1,5 +1,5 @@ using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Awares; using System.Windows.Controls; namespace SimpleNavigation.Tests; From 54da2c0c666fc1785a35454d7758482b78c8a7da Mon Sep 17 00:00:00 2001 From: Junevy Date: Mon, 13 Jul 2026 23:36:31 +0800 Subject: [PATCH 21/42] Add: IViewInitialize interface --- Common/NavigationAwareNotifier.cs | 38 +- Extensions/NavigationExtensions.cs | 20 +- Interface/Awares/IViewInitializeAware.cs | 18 + SimpleNavigation.csproj | 2 +- ...uild-design - \345\211\257\346\234\254.md" | 331 ++++++++++++++++++ 5 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 Interface/Awares/IViewInitializeAware.cs create mode 100644 "docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" diff --git a/Common/NavigationAwareNotifier.cs b/Common/NavigationAwareNotifier.cs index 5b16577..4172348 100644 --- a/Common/NavigationAwareNotifier.cs +++ b/Common/NavigationAwareNotifier.cs @@ -1,18 +1,38 @@ using SimpleNavigation.Interface.Awares; using System.Windows; -namespace SimpleNavigation.Common; - -internal static class NavigationAwareNotifier +namespace SimpleNavigation.Common { - public static void Notify(FrameworkElement target, DialogParameters? parameters) + /// + /// ¶ÔÓÚPage»òContentÀàÐ͵ĵ¼º½»Øµ÷ + /// + internal static class NavigationAwareNotifier { - if (target is INavigationAware targetAware) - targetAware.OnNavigated(parameters); + public static void Notify(FrameworkElement target, DialogParameters? parameters) + { + if (target is IViewInitializeAware view && !view.IsViewInitialized) + { + view.Initialize(); + view.IsViewInitialized = true; + } + + var dataContext = target.DataContext; + + if (dataContext is IViewInitializeAware contextViewAware + && !ReferenceEquals(contextViewAware, target) + && !contextViewAware.IsViewInitialized) + { + contextViewAware.Initialize(); + contextViewAware.IsViewInitialized = true; + } - var dataContext = target.DataContext; + if (target is INavigationAware targetAware) + targetAware.OnNavigated(parameters); - if (dataContext is INavigationAware contextAware && !ReferenceEquals(dataContext, target)) - contextAware.OnNavigated(parameters); + if (dataContext is INavigationAware contextAware && !ReferenceEquals(dataContext, target)) + contextAware.OnNavigated(parameters); + } } } + + diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index a8dec71..5f1f0c0 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -31,15 +31,15 @@ public static IServiceCollection AddPage(this IServiceCollection services where TPage : Page { AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); - services.TryAddTransient(); + services.TryAddSingleton(); return services; } public static IServiceCollection AddPage(this IServiceCollection services) where TPage : Page where TViewModel : class { - services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } @@ -47,8 +47,8 @@ public static IServiceCollection AddPage(this IServiceCollect where TPage : Page where TViewModel : class { AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); - services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } @@ -57,7 +57,7 @@ public static IServiceCollection AddContent(this IServiceCollection servi { ValidateContentType(typeof(TView)); AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); - services.TryAddTransient(); + services.TryAddSingleton(); return services; } @@ -65,8 +65,8 @@ public static IServiceCollection AddContent(this IServiceColl where TView : FrameworkElement where TViewModel : class { ValidateContentType(typeof(TView)); - services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } @@ -75,8 +75,8 @@ public static IServiceCollection AddContent(this IServiceColl { ValidateContentType(typeof(TView)); AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); - services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/Interface/Awares/IViewInitializeAware.cs b/Interface/Awares/IViewInitializeAware.cs new file mode 100644 index 0000000..97eb5fc --- /dev/null +++ b/Interface/Awares/IViewInitializeAware.cs @@ -0,0 +1,18 @@ +namespace SimpleNavigation.Interface.Awares +{ + /// + /// View åˆå§‹åŒ–完毕åŽï¼Œè‹¥éœ€è¦åˆå§‹åŒ–UI,则建议继承该接å£ï¼Œå°†UIåˆå§‹åŒ–é€»è¾‘ï¼ˆå¦‚å¯¼èˆªã€æ˜¾ç¤ºå†…容)写到Initialize方法中 + /// + public interface IViewInitializeAware + { + /// + /// 用于表示是å¦å·²è¿›è¡Œè¿‡åˆå§‹åŒ– + /// + bool IsViewInitialized { get; set; } + + /// + /// View åˆå§‹åŒ–完毕åŽï¼Œè‹¥éœ€è¦åˆå§‹åŒ–UI,则建议继承该接å£ï¼Œå°†UIåˆå§‹åŒ–é€»è¾‘ï¼ˆå¦‚å¯¼èˆªã€æ˜¾ç¤ºå†…容)写到Initialize方法中 + /// + void Initialize(); + } +} diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index b856f7b..f881e58 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,7 +8,7 @@ 13 true Junevy.SimpleNavigation - 2.0.0 + 2.0.1 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. $(DefaultItemExcludesInProjectFolder);Tests/** diff --git "a/docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" "b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" new file mode 100644 index 0000000..3114ea9 --- /dev/null +++ "b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" @@ -0,0 +1,331 @@ +# SimpleNavigation Rebuild Design + +## Status + +This design was approved section by section on 2026-07-11. All implementation work is based on the `rebuild` branch. + +## Summary + +The rebuild separates region ownership from navigation services, replaces the public `RegionService` attached-property owner with `Region`, adds navigation for non-page WPF content, and adds explicit string route aliases without replacing the existing generic and `Type` navigation paths. + +The library remains DI-driven and continues to target `net48` and `net8.0-windows`. The rebuild is intentionally breaking: `RegionService` is deleted rather than retained as a compatibility facade. + +## Goals + +- Move region registration, removal, and lookup out of `PageService` into `IRegionManager` and `RegionManager`. +- Rename the XAML attached-property owner from `RegionService` to `Region` and support `Frame` plus non-`Frame` `ContentControl` hosts. +- Add `IContentService` and `ContentService` for navigating to `FrameworkElement` content other than `Page` and `Window`. +- Add string key navigation to both page and content services through explicit route registration. +- Add a shared `INavigationAware` callback contract for navigated views and view models. +- Preserve generic and `Type` navigation as direct DI resolution paths. +- Keep host behavior extensible through internal adapters so `Panel` and `TabControl` can be added later without rewriting navigation services. +- Add automated tests for both target frameworks and update the README for the rebuilt API. + +## Non-goals + +- Do not implement `Grid`, `StackPanel`, `Panel`, or `TabControl` hosts in this change. +- Do not expose a public adapter plug-in API before those host semantics are defined. +- Do not set or replace a view's `DataContext`. +- Do not infer routes from type names, attributes, reflection scanning, or DI keyed services. +- Do not add back navigation to ordinary `ContentControl` regions. +- Do not add asynchronous navigation, navigation scopes, view caching, or automatic cross-thread dispatch. + +## Breaking Changes + +- Delete `SimpleNavigation.Services.RegionService` and its `RegionRegisted` event. +- Add `SimpleNavigation.Services.Region` as the only attached-property owner. XAML consumers must change `RegionService.RegionName` to `Region.RegionName`. +- Remove `GetRegion` from `IPageService`. +- Remove `RegisterRegion` and the region dictionary from `PageService`. +- Add correctly spelled `GoBack`. Retain `Goback` only as an obsolete forwarding member to avoid an unrelated hard break. +- Change page navigation awareness so the navigated view and its `DataContext` can both receive the shared callback. +- Change invalid navigation configuration from silent no-op behavior to explicit exceptions. + +## Architecture + +### Region declaration + +`Region` is a static attached-property owner. It declares `RegionNameProperty` and the standard `GetRegionName` and `SetRegionName` accessors. It contains no page or content navigation logic. + +An internal host adapter resolver validates each declared host. The resolver checks `Frame` before `ContentControl` because `Frame` inherits `ContentControl`. The built-in host set is: + +- `Frame`, owned by `PageService` and the frame adapter. +- Non-`Frame` `ContentControl`, owned by `ContentService` and the content-control adapter. + +The static declaration layer maintains weak registration records and publishes internal registration changes. A newly created `RegionManager` subscribes to changes before importing the current weak snapshot; registering the same name and host twice is idempotent. This ordering prevents both a snapshot-to-subscription race and regions declared during XAML initialization from being lost when DI creates the manager later. + +Changing a region name removes the old registration before adding the new registration. Clearing the property or unloading the host unregisters its attached-property ownership. Loading it again restores that ownership with a new activation token, so a delayed notification from an older load cycle cannot remove the new registration. Static declarations use weak references so an abandoned visual tree is not retained by the attached-property infrastructure. + +### Region manager + +`IRegionManager` is the public region ownership boundary: + +```csharp +public interface IRegionManager +{ + void RegisterRegion(string regionName, FrameworkElement region); + + bool UnregisterRegion(string regionName, FrameworkElement region); + + FrameworkElement? GetRegion(string regionName); + + TRegion? GetRegion(string regionName) + where TRegion : FrameworkElement; +} +``` + +`RegionManager` is registered as a singleton by `RegisterNavigationService`. It owns named lookup, imports attached-property declarations, and unsubscribes from static declaration notifications when disposed. The element argument on `UnregisterRegion` prevents a stale unload notification from removing a newer host that reused the same name. + +Both attached-property and programmatic registration pass through the same adapter resolver. Programmatic callers cannot register an unsupported host type to bypass `Region` validation. Named manager entries hold weak host references and remove dead entries during lookup or replacement. + +Programmatic and attached-property ownership are tracked independently for the same name and host. Public `RegisterRegion` and `UnregisterRegion` add or remove only programmatic ownership. Internal declaration notifications add or remove a specific activation token. A region remains available while either ownership source is active. This prevents `Unloaded` from accidentally removing a programmatically registered region and prevents a stale unload token from removing a reloaded declaration. + +Only one live host may own a region name. A second live host with the same name is a configuration error. A dead weak registration may be cleaned up and replaced. + +### Host adapters + +Adapters remain internal in this release: + +- `FrameRegionAdapter` verifies dispatcher access, calls `Frame.Navigate`, and implements journal back navigation. +- `ContentControlRegionAdapter` verifies dispatcher access and assigns `ContentControl.Content`. It explicitly refuses `Frame` hosts and has no journal capability. + +The adapter resolver is the only component that distinguishes concrete host types. Adding a future `PanelRegionAdapter` or `TabControlRegionAdapter` will extend this resolver and define the new host's presentation semantics without changing `PageService`, `ContentService`, or `RegionManager`. + +`Panel` support must eventually define whether the region owns all children before using `Children.Clear()` and `Children.Add()`. `TabControl` support must define whether navigation creates a tab, replaces the selected tab, or reuses an existing item, including how headers are supplied. Those policies are intentionally deferred. + +### Route registry + +An internal route registry holds two independent, ordinal, case-sensitive maps: + +- Page key to a type assignable to `Page`. +- Content key to a type assignable to `FrameworkElement`, excluding `Page` and `Window`. + +The same key may exist once in each map. A key may not be null, empty, or whitespace. Registering the same key twice in one map throws during service collection configuration, including when both registrations point to the same type. + +The route registry stores only aliases. It does not create instances and does not use Microsoft DI keyed services. This keeps behavior identical for the DI 6 dependency used by `net48` and the DI 8 dependency used by `net8.0-windows`. + +## Public Navigation APIs + +### Page navigation + +```csharp +public interface IPageService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); + + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); +} +``` + +`PageService` accepts only `Frame` regions and only `Page` targets. + +### Content navigation + +```csharp +public interface IContentService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); +} +``` + +`ContentService` accepts only non-`Frame` `ContentControl` regions. A content target may be a `UserControl`, ordinary `ContentControl`, custom control, `Grid`, `StackPanel`, or another `FrameworkElement`; `Page` and `Window` targets are rejected. A `Frame` may be content, but a `Frame` may not be the region host used by `ContentService`. + +### Navigation awareness + +```csharp +public interface INavigationAware +{ + void OnNavigated(DialogParameters? parameters); +} +``` + +`IPageAware` inherits `INavigationAware` and retains its existing `Receive` event. A successful navigation synchronously checks both the target view and its `DataContext`. Every distinct object that implements `INavigationAware` is invoked once. If the view and `DataContext` reference the same instance, it is invoked only once. The callback is invoked even when `parameters` is null. + +The library never assigns `DataContext`. Constructor injection, factories, XAML, and view-to-view-model wiring remain application responsibilities. + +## Service Collection Extensions + +`NavigationExtensions` adds the following overloads: + +```csharp +IServiceCollection AddPage(string key) + where TPage : Page; + +IServiceCollection AddPage() + where TPage : Page + where TViewModel : class; + +IServiceCollection AddPage(string key) + where TPage : Page + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement; + +IServiceCollection AddContent() + where TView : FrameworkElement + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement + where TViewModel : class; +``` + +Registration behavior is: + +- Single-generic key overload: `TryAddTransient` the view and add its route alias. +- Double-generic overload without key: `TryAddTransient` the view and view model, with no route alias. +- Double-generic key overload: `TryAddTransient` the view and view model, then add the route alias. +- `AddContent` performs a runtime guard that rejects `Page` and `Window`, which cannot be expressed as a negative generic constraint. +- Existing application registrations are not replaced. To select a custom lifetime or factory, register it before calling the navigation extension. +- The extension does not set `DataContext` and does not infer view-model interfaces. + +`AddPage` and `AddContent` may be called before or after `RegisterNavigationService` as long as all calls occur before `BuildServiceProvider`. + +## Navigation Data Flow + +The three overload families deliberately have separate resolution paths. + +### Generic navigation + +```csharp +var target = provider.GetRequiredService(); +``` + +Generic navigation never reads the route registry. + +### Type navigation + +```csharp +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +The service validates assignability before resolving. Type navigation never reads the route registry. + +### String key navigation + +```csharp +var targetType = routes.GetRequiredType(key); +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +String navigation first resolves the alias to a type in the appropriate page or content map, then uses the ordinary DI container to create the instance. It does not call `GetRequiredKeyedService`. + +After target resolution, every navigation follows this order: + +1. Validate the region name and target input. +2. Resolve the target instance through the selected path above. +3. Retrieve the named region and require the correct host adapter. +4. Verify access to the host dispatcher. +5. Ask the adapter to present or navigate to the target. +6. If the host accepted navigation, invoke navigation awareness on the view and its `DataContext`. + +For `Frame`, the awareness callback runs synchronously after `Frame.Navigate` returns `true`; it does not wait for `Loaded`, `Navigated`, or `LoadCompleted`. For `ContentControl`, it runs after the `Content` assignment succeeds. + +`GoBack` retrieves the named `Frame`, verifies dispatcher access, and calls `GoBack` only when `CanGoBack` is true. A valid frame with no journal entry is a no-op. `Goback` forwards to `GoBack`. + +## Error Handling + +- Null, empty, or whitespace region names and route keys throw `ArgumentException`. +- An unregistered key throws `KeyNotFoundException` and names the key plus the page or content route category. +- A duplicate key in one route category throws `ArgumentException` while configuring `IServiceCollection`. +- A missing region or a region with the wrong host type throws `InvalidOperationException` and names the region, actual type when available, and expected host type. +- A non-`Page` target passed to `PageService` throws `ArgumentException`. +- A `Page` or `Window` target passed to `ContentService` throws `ArgumentException`. +- A missing DI registration retains the original `GetRequiredService` exception. +- A resolved object that does not match the validated expected base type throws `InvalidOperationException` rather than producing a null navigation target. +- Host, dispatcher, content assignment, navigation, and `INavigationAware` exceptions propagate to the caller. +- If `Frame.Navigate` returns `false`, awareness callbacks are not invoked. +- A second live region with an existing name throws `InvalidOperationException`. +- Attaching `RegionName` to, or programmatically registering, a host without a built-in adapter throws `ArgumentException` and names the unsupported type. +- `GetRegion` returns null when a name is absent; navigation services convert absence into the fail-fast exception above. +- `UnregisterRegion` returns false when the name is absent or belongs to a different element. + +## Threading And Lifetime + +Navigation APIs are synchronous and must be called on the region host's dispatcher thread. Adapters call `VerifyAccess`; the library does not marshal work to another dispatcher. + +`IRegionManager`, `IPageService`, `IContentService`, and the internal route registry are DI singletons. Views and view models added by navigation extensions default to transient through `TryAddTransient`. Existing application registrations control their actual lifetime. + +Region declarations and manager lookups use weak references where ownership is not required. Active WPF hosts unregister on unload, and `RegionManager` releases static subscriptions when disposed. + +## Planned File Structure + +- Delete `Services/RegionService.cs`. +- Create `Services/Region.cs` for the attached property and weak declaration notifications. +- Create `Interface/IRegionManager.cs` and `Common/RegionManager.cs` for region ownership. +- Create focused internal adapter files for `Frame` and `ContentControl` host behavior. +- Create `Interface/IContentService.cs` and `Services/ContentService.cs`. +- Create `Interface/INavigationAware.cs` and update `Interface/IPageAware.cs`. +- Update `Interface/IPageService.cs` and `Services/PageService.cs`. +- Add an internal page/content route registry and route descriptors. +- Update `Extensions/NavigationExtensions.cs` with core service registration and the six route/view overloads. +- Add `Tests/SimpleNavigation.Tests` to the solution. +- Update `README.md` with the rebuilt API, examples, limitations, and migration note. + +## Testing Strategy + +Tests use xUnit and an STA helper for WPF behavior. The test project targets `net48` and `net8.0-windows`. + +Coverage includes: + +- `Region` declaration on `Frame` and ordinary `ContentControl`. +- Rename, clear, unload, reload, weak cleanup, late `RegionManager` creation, and duplicate region detection. +- Programmatic register, unregister, unregistration ownership, untyped lookup, and typed lookup. +- Mixed programmatic and attached ownership, including unload/reload activation-token ordering. +- Rejection of unsupported hosts through both attached-property and programmatic registration paths. +- All six `AddPage` and `AddContent` overloads. +- Default transient registration, preservation of an existing singleton, duplicate keys, separate page/content key spaces, and key validation. +- Generic, `Type`, and string navigation for pages and content. +- Proof that generic and `Type` paths do not require a route alias. +- Proof that string paths resolve the dictionary type and then use the ordinary DI registration. +- Content host replacement and frame journal navigation. +- `GoBack`, no-journal behavior, and obsolete `Goback` forwarding. +- Rejection of page targets, window targets, frame content hosts, wrong region hosts, missing regions, invalid types, missing DI registrations, and unknown keys. +- View and `DataContext` awareness, reference de-duplication, null parameters, callback ordering, rejected navigation, and callback exception propagation. +- Dispatcher-thread enforcement. +- Successful Debug and Release builds for both library target frameworks. + +Each production behavior is implemented with a red-green-refactor cycle. The final verification runs the complete test suite and builds the solution for both targets. + +## Documentation + +The README will: + +- Replace every `RegionService.RegionName` example with `Region.RegionName`. +- Document the removal of `RegionService` as a breaking migration. +- Document `IRegionManager`, `IContentService`, and `INavigationAware`. +- Show the six `AddPage` and `AddContent` registration forms. +- Show generic, `Type`, and string key navigation. +- Explain that route aliases map strings to types while DI remains responsible for instances. +- State that the library never assigns `DataContext`. +- State that content regions currently require a non-`Frame` `ContentControl`. +- Explain how the internal adapter boundary permits future `Panel` and `TabControl` support without claiming those hosts work today. From 4cb73bceb2fa15313a1669bd641f02622c83094e Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 20:15:47 +0800 Subject: [PATCH 22/42] docs: design dialog service rebuild --- ...026-07-16-dialog-service-rebuild-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md diff --git a/docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md b/docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md new file mode 100644 index 0000000..50f0cc3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md @@ -0,0 +1,183 @@ +# DialogService Rebuild Design + +## Goal + +Rebuild `IDialogService` so Window registration and resolution follow the same ordinary-DI plus explicit-route model used by Page and Content navigation. Add generic, `Type`, and string-key operations for non-modal display, modal display, and closing an existing Window. + +## Scope + +This change will: + +- Add Window and optional ViewModel registration extensions. +- Add an independent, ordinal, case-sensitive Dialog route namespace. +- Add generic, `Type`, and key overloads to `IDialogService`. +- Make `Close` operate only on existing managed Window instances and return a success result. +- Rework `DialogManager` to safely create, reuse, remove, and recreate transient Window instances. +- Fix the existing `ShowDialog` close/result re-entry bug. +- Preserve application ownership of `DataContext` and DI lifetimes. + +This change will not: + +- Set or replace a Window's `DataContext`. +- Use Microsoft DI keyed services. +- Create or dispose DI scopes for Window lifetimes. +- Automatically marshal Window operations across Dispatcher threads. + +## Registration API + +Add three `IServiceCollection` extensions: + +```csharp +AddWindow(string key) +AddWindow() +AddWindow(string key) +``` + +Constraints: + +```csharp +where TWindow : Window +where TViewModel : class +``` + +All extensions use `TryAddTransient`. Earlier application registrations are preserved, and the extensions never assign `DataContext`. + +The single-generic overload registers the Window and its key. The double-generic overload without a key registers the Window and ViewModel only. The double-generic keyed overload registers both services and the Window route. + +## Route Registry + +Extend `NavigationRouteKind` with `Dialog` and add a third immutable route map to `NavigationRouteRegistry`. + +```csharp +Type GetRequiredDialogType(string key); +``` + +Page, Content, and Dialog key spaces are independent. Each space uses `StringComparer.Ordinal`, so keys are case-sensitive. A duplicate key within Dialog registration throws `ArgumentException`, while the same key may exist in Page, Content, and Dialog maps. + +String operations resolve in two stages: + +```text +key -> Dialog route Type -> ordinary IServiceProvider.GetRequiredService(Type) +``` + +DI keyed services are not used. + +## IDialogService Contract + +The rebuilt interface is: + +```csharp +public interface IDialogService +{ + void Show(DialogParameters? parameters = null) + where TWindow : Window; + + void Show(Type targetType, DialogParameters? parameters = null); + + void Show(string key, DialogParameters? parameters = null); + + DialogParameters? ShowDialog( + DialogParameters? parameters = null) + where TWindow : Window; + + DialogParameters? ShowDialog( + Type targetType, + DialogParameters? parameters = null); + + DialogParameters? ShowDialog( + string key, + DialogParameters? parameters = null); + + bool Close() where TWindow : Window; + + bool Close(Type targetType); + + bool Close(string key); +} +``` + +Generic and `Type` operations require only ordinary DI registration. String operations require an explicit Dialog route and then resolve the mapped type through ordinary DI. + +## DialogManager Responsibilities + +`DialogManager` manages the current Window instance by Window `Type`, not by route key. Two keys mapped to the same Window type therefore address the same current instance. + +It exposes capabilities equivalent to: + +- Get or create a Window of a validated type through ordinary DI. +- Get an existing managed Window without creating one. +- Remove a Window when that exact instance raises `Closed`. + +The instance map holds weak references. Removal checks both the type and exact Window reference so a delayed `Closed` notification from an old instance cannot remove a replacement instance. + +### Window lifetime + +1. The first `Show` or `ShowDialog` resolves a transient Window through DI, stores a weak reference, and subscribes to `Closed`. +2. Repeated `Show` while the Window is open reuses the same instance. It sends the new navigation parameters, calls `Show()` only when the Window is not visible, and then calls `Activate()`. +3. When the Window closes, `DialogManager` removes the exact cached instance. +4. A later display resolves a new transient Window through DI. +5. `Close` never creates a Window. + +## Show And ShowDialog Behavior + +Before presentation, the service validates the target type and obtains the Window from `DialogManager`. + +Navigation awareness is delivered in this order: + +1. Window when it implements `IDialogAware`. +2. Window `DataContext` when it implements `IDialogAware` and is not the same reference as the Window. + +Both receive `OnNavigated(parameters)`. The service never assigns `DataContext`. + +For non-modal `Show`, `RequestClose` closes the current Window. Repeated calls update awareness and close delegates for the current instance. + +For modal `ShowDialog`: + +- `RequestClose(result)` stores the result and calls `Window.Close()`. +- The current unconditional `Closing` cancellation and recursive close path are removed. +- Closing through the system close button is allowed and returns `null` when no result was supplied. +- Both Window and distinct DataContext close delegates are cleared when modal display ends. +- Window, DI, Dispatcher, and awareness exceptions propagate to the caller. + +## Close Behavior + +All close overloads resolve a Window type, then query `DialogManager` for an existing instance without using DI. + +- Unknown string key throws `KeyNotFoundException`. +- A registered type with no current Window returns `false`. +- A Window that exists but is not focused may still be closed; WPF `IsActive` is not a prerequisite. +- `Close()` is called on the Window's Dispatcher thread after `VerifyAccess()`. +- If `Closing` cancels the operation, the method returns `false`. +- If the Window raises `Closed` and is removed from the manager, the method returns `true`. + +## Validation And Error Handling + +- Null `Type` throws `ArgumentNullException`. +- A non-Window `Type` throws `ArgumentException`. +- A null, empty, or whitespace route key throws `ArgumentException`. +- An unknown Dialog key throws `KeyNotFoundException`. +- Missing DI registration preserves the original `GetRequiredService` exception. +- Window operations require the Window Dispatcher thread; the library does not marshal automatically. +- `IDialogAware` exceptions propagate. + +## Testing Strategy + +Tests target both `net48` and `net8.0-windows` and cover: + +- All three `AddWindow` overloads and transient registration. +- Preservation of earlier application registrations. +- Independent Page, Content, and Dialog route namespaces. +- Ordinal key casing, duplicate Dialog keys, invalid keys, and unknown keys. +- Generic, `Type`, and key `Show` paths. +- Generic, `Type`, and key `ShowDialog` paths and result propagation. +- Generic, `Type`, and key `Close` paths. +- Close-before-show returning `false` without creating a Window. +- Closing cancellation returning `false`. +- Reuse while open and recreation after `Closed`. +- Window and DataContext awareness order, de-duplication, and exception propagation. +- Dispatcher enforcement and DI exception preservation. +- Regression coverage for Page, Content, Region, and registration behavior. + +## Documentation And Migration + +README examples will show the three Window registration extensions, generic/`Type`/key display operations, key-based close, and transient recreation after close. The existing warning about broken modal closure will be removed only after the repaired behavior is verified by tests. From ed8fde9f77a10c6596c197051e37cfe73f371e94 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 20:26:38 +0800 Subject: [PATCH 23/42] docs: plan dialog service rebuild --- .../2026-07-16-dialog-service-rebuild-plan.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md diff --git a/docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md b/docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md new file mode 100644 index 0000000..4956a5d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md @@ -0,0 +1,649 @@ +# DialogService Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add transient Window registration, independent Dialog routes, key-based Show/ShowDialog/Close operations, safe Window recreation, and accurate README guidance. + +**Architecture:** Extend the existing `NavigationRouteRegistry` with an independent Dialog map. `DialogManager` owns weak references to current Window instances by Window type and exposes separate get-or-create and get-existing paths. `DialogService` resolves generic/Type/key targets through ordinary DI, delegates instance lifecycle to `DialogManager`, and keeps all Window operations on the Window Dispatcher. + +**Tech Stack:** C# 13, WPF, Microsoft.Extensions.DependencyInjection 6/8, xUnit 2.5.3, .NET Framework 4.8, .NET 8 Windows. + +--- + +## Scope and baseline + +The current workspace contains user-owned uncommitted changes in `Services/Region.cs` and `SimpleNavigation.csproj`; preserve them. The current baseline also has unrelated failures caused by the user's singleton Page/Content registrations, Region namespace reorganization, and stale reflection test names. Dialog implementation must not revert those changes. Use the stable SDK invocation: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" +``` + +Existing public namespaces are currently split as follows: + +- `Interface.Services`: `IDialogService`, `IPageService`, `IContentService`. +- `Interface.Managers`: `IDialogManager`, `IRegionManager`. +- `Interface.Awares`: `IDialogAware`, `INavigationAware`. +- `Common.Managers`: `DialogManager`, `RegionManager`. + +Do not stage `Services/Region.cs`, `SimpleNavigation.csproj`, or the untracked design-copy file. + +## File map + +- Modify `Common/NavigationRouteRegistry.cs`: add the Dialog route kind, Dialog map, and `GetRequiredDialogType`. +- Modify `Extensions/NavigationExtensions.cs`: add the three `AddWindow` overloads and Dialog route registration. +- Modify `Interface/Managers/IDialogManager.cs`: add type-based get-or-create and get-existing operations. +- Modify `Common/Managers/DialogManager.cs`: maintain weak current Window instances by exact type and remove only the exact closed instance. +- Modify `Interface/Services/IDialogService.cs`: add generic, Type, and key Show/ShowDialog/Close overloads. +- Modify `Services/DialogService.cs`: implement all resolution paths, awareness, non-modal display, modal result handling, and bool Close behavior. +- Modify `Tests/SimpleNavigation.Tests/TestTypes.cs`: add Window, dialog-aware ViewModel, and close-cancel fixtures. +- Modify `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs`: add Dialog route and AddWindow coverage; preserve existing user changes. +- Create `Tests/SimpleNavigation.Tests/DialogManagerTests.cs`: test instance creation, reuse, exact Closed removal, and no-create lookup. +- Create `Tests/SimpleNavigation.Tests/DialogServiceTests.cs`: test Show, ShowDialog, Close, key resolution, awareness, Dispatcher, and error behavior. +- Modify `README.md`: document AddWindow, all Dialog service overloads, key-based close, transient recreation, and the repaired modal behavior. + +## Task 1: Add Dialog route registration and Window extensions + +**Files:** + +- Modify: `Common/NavigationRouteRegistry.cs` +- Modify: `Extensions/NavigationExtensions.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Modify: `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs` + +- [ ] **Step 1: Add Window and ViewModel fixtures first** + +Add these test-only types to `TestTypes.cs`: + +```csharp +public sealed class FirstWindow : Window +{ +} + +public sealed class SecondWindow : Window +{ +} + +public sealed class DialogViewModel +{ +} +``` + +Use `System.Windows` and the existing `SimpleNavigation.Interface.Awares` namespace only where a fixture needs awareness. + +- [ ] **Step 2: Add failing registration tests** + +Add tests to `NavigationExtensionsTests.cs`: + +```csharp +[Fact] +public void AddWindowKeyRegistersTransientWindowAndDialogRoute() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); +} + +[Fact] +public void AddWindowDoubleGenericRegistersWindowAndViewModelWithoutDataContext() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddWindow(); + using var provider = services.BuildServiceProvider(); + + var window = provider.GetRequiredService(); + Assert.Null(window.DataContext); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); +} + +[Fact] +public void DialogKeysAreIndependentFromPageAndContentKeys() +{ + var services = new ServiceCollection(); + services.AddPage("main"); + services.AddContent("main"); + services.AddWindow("main"); +} + +[Fact] +public void DuplicateDialogKeyIsRejectedUsingOrdinalMatching() +{ + var services = new ServiceCollection(); + services.AddWindow("main"); + + Assert.Throws( + () => services.AddWindow("main")); +} +``` + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter "FullyQualifiedName~AddWindow|FullyQualifiedName~DialogKey|FullyQualifiedName~DuplicateDialog" --no-restore +``` + +Expected: compile failures for missing `AddWindow` and missing Dialog route support. Do not change production code before observing this failure. + +- [ ] **Step 4: Extend the route registry** + +Change the internal enum and registry with the following behavior: + +```csharp +internal enum NavigationRouteKind +{ + Page, + Content, + Dialog, +} + +private readonly IReadOnlyDictionary dialogs; + +public Type GetRequiredDialogType(string key) => + GetRequiredTarget(dialogs, key, "dialog"); +``` + +Build `dialogs` with the same `StringComparer.Ordinal` and category filter used by the Page and Content maps. Do not combine the dictionaries. + +- [ ] **Step 5: Add the three Window extensions** + +Add these methods to `NavigationExtensions`: + +```csharp +public static IServiceCollection AddWindow( + this IServiceCollection services, + string key) + where TWindow : Window +{ + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddWindow( + this IServiceCollection services) + where TWindow : Window + where TViewModel : class +{ + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddWindow( + this IServiceCollection services, + string key) + where TWindow : Window + where TViewModel : class +{ + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} +``` + +Reuse the existing whitespace validation and category-local ordinal duplicate detection in `AddRoute`. Do not set `DataContext`. + +- [ ] **Step 6: Add route lookup and registration-order tests** + +Extend the existing reflection helper in `NavigationExtensionsTests` to call `GetRequiredDialogType`. Test a Dialog route registered before `RegisterNavigationService`, one registered after it but before `BuildServiceProvider`, ordinal casing, unknown key, and invalid key. Tests that already pass because the implementation is present are coverage additions; report them honestly rather than claiming a false RED cycle. + +- [ ] **Step 7: Run focused GREEN and commit Task 1** + +Run the focused extension tests on both targets: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --filter "FullyQualifiedName~AddWindow|FullyQualifiedName~DialogKey|FullyQualifiedName~DuplicateDialog|FullyQualifiedName~DialogRoute" --no-restore +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter "FullyQualifiedName~AddWindow|FullyQualifiedName~DialogKey|FullyQualifiedName~DuplicateDialog|FullyQualifiedName~DialogRoute" --no-restore +``` + +Commit only the route/extension and test changes: + +```powershell +git add Common/NavigationRouteRegistry.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs Tests/SimpleNavigation.Tests/TestTypes.cs +git commit -m "feat: add dialog window registration routes" +``` + +## Task 2: Refactor DialogManager instance lifecycle + +**Files:** + +- Modify: `Interface/Managers/IDialogManager.cs` +- Modify: `Common/Managers/DialogManager.cs` +- Create: `Tests/SimpleNavigation.Tests/DialogManagerTests.cs` + +- [ ] **Step 1: Write failing manager tests** + +Add tests that express the desired type-based operations: + +```csharp +[Fact] +public void GetOrCreateReusesLiveWindowAndGetExistingDoesNotCreate() +{ + StaTest.Run(() => + { + var expected = new FirstWindow(); + var services = new ServiceCollection(); + services.AddSingleton(expected); + using var provider = services.BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Null(manager.GetExistingWindow(typeof(FirstWindow))); + Assert.Same(expected, manager.GetOrCreateWindow(typeof(FirstWindow))); + Assert.Same(expected, manager.GetExistingWindow(typeof(FirstWindow))); + }); +} + +[Fact] +public void ClosedWindowIsRemovedAndNextGetOrCreateUsesDIAgain() +{ + StaTest.Run(() => + { + var created = 0; + var services = new ServiceCollection(); + services.AddTransient(_ => + { + created++; + return new FirstWindow(); + }); + using var provider = services.BuildServiceProvider(); + var manager = new DialogManager(provider); + + var first = manager.GetOrCreateWindow(typeof(FirstWindow)); + first.Show(); + first.Close(); + Assert.Null(manager.GetExistingWindow(typeof(FirstWindow))); + + var second = manager.GetOrCreateWindow(typeof(FirstWindow)); + Assert.NotSame(first, second); + Assert.Equal(2, created); + second.Show(); + second.Close(); + }); +} +``` + +Every created Window is shown and closed on the STA thread so the real WPF `Closed` event drives cache removal. Do not add production-only test hooks. + +- [ ] **Step 2: Run manager tests and verify RED** + +Run the new test class on `net8.0-windows`; expected failure is missing `GetExistingWindow`/`GetOrCreateWindow` methods or the old type cache behavior. + +- [ ] **Step 3: Implement type-based manager operations** + +Refactor `IDialogManager` to expose: + +```csharp +Window GetOrCreateWindow(Type windowType); +Window? GetExistingWindow(Type windowType); +``` + +`DialogManager` must: + +- Validate that `windowType` is non-null and derives from `Window`. +- Use ordinary `provider.GetRequiredService(windowType)` only in `GetOrCreateWindow`. +- Store `WeakReference` by exact `Type`. +- Subscribe once to each Window's `Closed` event. +- Remove a cache entry only when both cached type and cached Window reference match the closed sender. +- Return null from `GetExistingWindow` for an absent or dead weak reference without resolving DI. + +Keep the generic `GetDialogWindow` only if existing consumers require source compatibility; implement it as a thin call to `GetOrCreateWindow(typeof(T))`. + +- [ ] **Step 4: Run manager tests on both targets and commit** + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --filter FullyQualifiedName~DialogManagerTests --no-restore +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~DialogManagerTests --no-restore +``` + +Commit: + +```powershell +git add Interface/Managers/IDialogManager.cs Common/Managers/DialogManager.cs Tests/SimpleNavigation.Tests/DialogManagerTests.cs +git commit -m "refactor: manage dialog windows by type" +``` + +## Task 3: Rebuild IDialogService and DialogService + +**Files:** + +- Modify: `Interface/Services/IDialogService.cs` +- Modify: `Services/DialogService.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/DialogServiceTests.cs` + +- [ ] **Step 1: Add dialog-aware and cancellation fixtures** + +Add these test types: + +```csharp +public sealed class AwareDialogViewModel : IDialogAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + public Action? RequestClose { get; set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwareWindow : Window, IDialogAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + public Action? RequestClose { get; set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class CancelClosingWindow : Window +{ + public bool CancelClose { get; set; } = true; + + public CancelClosingWindow() + { + Closing += (_, args) => args.Cancel = CancelClose; + } +} +``` + +- [ ] **Step 2: Write failing service tests** + +Create `DialogServiceTests.cs` with focused tests for: + +```csharp +[Fact] +public void KeyShowResolvesTheDialogRouteThroughOrdinaryDI() +{ + StaTest.Run(() => + { + var expected = new FirstWindow(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Show("first"); + Assert.True(expected.IsVisible); + expected.Close(); + }); +} + +[Fact] +public void CloseBeforeShowReturnsFalseWithoutResolvingAWindow() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + Assert.False(provider.GetRequiredService().Close("first")); + }); +} + +[Fact] +public void UnknownDialogKeyThrowsKeyNotFoundExceptionForShowAndClose() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Show("missing")); + Assert.Throws(() => service.Close("missing")); + }); +} +``` + +Add these concrete tests in the same class: + +```csharp +[Fact] +public void GenericAndTypeShowDoNotRequireRoutes() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.True(provider.GetRequiredService() + .GetExistingWindow(typeof(FirstWindow))!.IsVisible); + Assert.True(service.Close()); + + service.Show(typeof(SecondWindow)); + Assert.True(provider.GetRequiredService() + .GetExistingWindow(typeof(SecondWindow))!.IsVisible); + Assert.True(service.Close(typeof(SecondWindow))); + }); +} + +[Fact] +public void WindowAndDistinctDataContextReceiveParametersOnceEach() +{ + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + var parameters = new DialogParameters("id", 7); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(window); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService() + .Show(parameters); + + Assert.Equal(1, window.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, window.Parameters); + Assert.Same(parameters, viewModel.Parameters); + window.Close(); + }); +} + +[Fact] +public void CancelledCloseReturnsFalseAndKeepsTheWindowManaged() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.False(service.Close()); + var window = Assert.IsType( + provider.GetRequiredService() + .GetExistingWindow(typeof(CancelClosingWindow))); + window.CancelClose = false; + window.Close(); + }); +} +``` + +Also add one cross-thread test that creates and shows the Window on an STA thread, then calls `Close` from the test thread and asserts `InvalidOperationException`. Never leave a shown Window open at the end of a test; set `CancelClose = false` before closing cancellation fixtures during cleanup. + +Add this modal result regression test: + +```csharp +[Fact] +public void ShowDialogReturnsParametersFromRequestClose() +{ + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + var expected = new DialogParameters("saved", true); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(window); + using var provider = services.BuildServiceProvider(); + + window.Dispatcher.BeginInvoke(new Action(() => + viewModel.RequestClose!.Invoke(expected))); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Same(expected, actual); + Assert.Null(viewModel.RequestClose); + Assert.Null(window.RequestClose); + }); +} +``` + +Add a second modal test that schedules `window.Close()` instead of `RequestClose` and asserts a null result. Both tests must fail against the current unconditional `Closing` cancellation implementation. + +- [ ] **Step 3: Run focused tests and verify RED** + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~DialogServiceTests --no-restore +``` + +Expected failures include missing overloads and the current modal close/result re-entry behavior. + +- [ ] **Step 4: Implement the public service contract** + +Replace `IDialogService` with the approved generic/Type/key overloads for `Show`, `ShowDialog`, and `Close`. Keep the existing namespace `SimpleNavigation.Interface.Services`. + +- [ ] **Step 5: Implement resolution and validation helpers** + +`DialogService` should inject `IServiceProvider`, `IDialogManager`, and `NavigationRouteRegistry` resolved from DI. Implement: + +```csharp +private static Type ValidateWindowType(Type targetType) +{ + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + if (!typeof(Window).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Window.", + nameof(targetType)); + return targetType; +} + +private Type ResolveDialogKey(string key) => + routes.GetRequiredDialogType(key); + +private Window GetOrCreateWindow(Type targetType) => + dialogManager.GetOrCreateWindow(ValidateWindowType(targetType)); +``` + +Generic and Type operations use ordinary DI through `DialogManager`; key operations resolve the route type first. A non-Window Type throws `ArgumentException`, null Type throws `ArgumentNullException`, and missing DI registrations propagate. + +- [ ] **Step 6: Implement awareness and non-modal Show** + +Create one helper that notifies Window first, then a distinct DataContext. Set `RequestClose` on both distinct awareness objects to close the same Window. For an already visible Window, call `Activate()` without calling `Show()` again. For a hidden but existing Window, call `Show()` then `Activate()`. + +- [ ] **Step 7: Implement modal ShowDialog and Close** + +For `ShowDialog`, attach close delegates that store the result then call `Close()`, allow the `Closing` event to proceed, call `ShowDialog()`, and clear both delegates in `finally`. + +For `Close`, resolve the type/key, call `GetExistingWindow`, return false if null, call `Dispatcher.VerifyAccess`, subscribe a one-shot `Closed` observer, call `Close()`, and return whether the instance actually closed. Do not use `Window.IsActive` as a prerequisite. Detach the one-shot handler in all paths. + +- [ ] **Step 8: Run service tests on both targets and commit Task 3** + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --filter FullyQualifiedName~DialogServiceTests --no-restore +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~DialogServiceTests --no-restore +``` + +Then run the Dialog manager, extension, Page, Content, and Region tests. Commit only the Dialog interface/service/manager and associated tests: + +```powershell +git add Interface/Services/IDialogService.cs Services/DialogService.cs Interface/Managers/IDialogManager.cs Common/Managers/DialogManager.cs Common/NavigationRouteRegistry.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/DialogServiceTests.cs Tests/SimpleNavigation.Tests/DialogManagerTests.cs Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs Tests/SimpleNavigation.Tests/TestTypes.cs +git commit -m "feat: rebuild dialog service around keyed windows" +``` + +## Task 4: Update README and complete verification + +**Files:** + +- Modify: `README.md` + +- [ ] **Step 1: Document Window registration and navigation** + +Add examples: + +```csharp +services.AddWindow("login"); +services.AddWindow(); +services.AddWindow("reports"); + +dialogService.Show(); +dialogService.Show("reports"); +dialogService.ShowDialog("login"); +dialogService.Close("reports"); +``` + +Explain that keys are independent from Page and Content keys, Window registrations are transient, instances are reused while open, and a fresh instance is created after `Closed`. + +Remove the old warning that `ShowDialog` is broken after the repaired tests pass. Document that `Close` returns false when no instance is currently managed and that unknown keys throw. + +- [ ] **Step 2: Verify README names and migration guidance** + +Run: + +```powershell +rg -n "AddWindow|ShowDialog|Close\(|Dialog key|transient|RegionService" README.md +git diff --check +``` + +Ensure the existing Region migration text and current user-owned changes are untouched. + +- [ ] **Step 3: Run final verification without reverting baseline user changes** + +First run the Dialog-focused suites on both targets and record results. Then run the full suite with the stable SDK. If the five baseline failures remain, report them separately and do not change unrelated Region/Page tests unless the user explicitly requests baseline repair. + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" restore SimpleNavigation.sln --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Debug --no-restore --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --no-build --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --no-build --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Release --no-restore --nologo +``` + +- [ ] **Step 4: Audit the final diff** + +Run: + +```powershell +rg -n "RegionService" Common Extensions Interface Services SimpleNavigation.csproj +git diff --check +git status --short --branch +``` + +Do not stage or commit `Services/Region.cs`, `SimpleNavigation.csproj`, or any untracked user file. The final report must distinguish Dialog tests from the pre-existing five baseline failures if they remain. From 8971c140aeb7256722019cd1d0479640a17dc9ec Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 20:37:53 +0800 Subject: [PATCH 24/42] feat: add dialog window registration routes --- Common/NavigationRouteRegistry.cs | 5 + Extensions/NavigationExtensions.cs | 25 +++ .../NavigationExtensionsTests.cs | 151 ++++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 13 ++ 4 files changed, 194 insertions(+) diff --git a/Common/NavigationRouteRegistry.cs b/Common/NavigationRouteRegistry.cs index 6601191..6f76b8d 100644 --- a/Common/NavigationRouteRegistry.cs +++ b/Common/NavigationRouteRegistry.cs @@ -7,6 +7,7 @@ internal enum NavigationRouteKind { Page, Content, + Dialog, } /// @@ -32,17 +33,21 @@ internal sealed class NavigationRouteRegistry { private readonly IReadOnlyDictionary pages; private readonly IReadOnlyDictionary contents; + private readonly IReadOnlyDictionary dialogs; public NavigationRouteRegistry(IEnumerable registrations) { pages = BuildRoute(registrations, NavigationRouteKind.Page); contents = BuildRoute(registrations, NavigationRouteKind.Content); + dialogs = BuildRoute(registrations, NavigationRouteKind.Dialog); } public Type GetRequiredPageType(string key) => GetRequiredTarget(pages, key, "page"); public Type GetRequiredContentType(string key) => GetRequiredTarget(contents, key, "content"); + public Type GetRequiredDialogType(string key) => GetRequiredTarget(dialogs, key, "dialog"); + /// /// Build µ¼º½Â·ÓÉ /// diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index 5f1f0c0..8eab5fe 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -80,6 +80,31 @@ public static IServiceCollection AddContent(this IServiceColl return services; } + public static IServiceCollection AddWindow(this IServiceCollection services, string key) + where TWindow : Window + { + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddWindow(this IServiceCollection services) + where TWindow : Window where TViewModel : class + { + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddWindow(this IServiceCollection services, string key) + where TWindow : Window where TViewModel : class + { + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + private static void AddRoute(IServiceCollection services, NavigationRouteKind kind, string key, Type targetType) { if (string.IsNullOrWhiteSpace(key)) diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index 3d5a8b6..9c6c045 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -278,6 +278,157 @@ public void InvalidContentRouteKey_IsRejected(string? key) Assert.Equal("key", exception.ParamName); } + [Fact] + public void DialogRegistration_AddWindowWithKey_RegistersTransientWindowAndRoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddWindow("first"); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "first")); + }); + } + + [Fact] + public void DialogRegistration_AddWindowWithViewModel_RegistersBothTransientWithoutSettingDataContext() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddWindow(); + using var provider = services.BuildServiceProvider(); + + var firstWindow = provider.GetRequiredService(); + var secondWindow = provider.GetRequiredService(); + + Assert.NotSame(firstWindow, secondWindow); + Assert.Null(firstWindow.DataContext); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void DialogRegistration_AddWindowWithViewModelAndKey_RegistersBothTransientAndRoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "first")); + }); + } + + [Fact] + public void DialogRegistration_AddWindowPreservesEarlierSingletonRegistrations() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + var viewModel = new DialogViewModel(); + var services = new ServiceCollection(); + services.AddSingleton(window); + services.AddSingleton(viewModel); + + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + Assert.Same(window, provider.GetRequiredService()); + Assert.Same(viewModel, provider.GetRequiredService()); + }); + } + + [Fact] + public void DialogRoutes_UseOrdinalCaseSensitiveKeysAndRejectDuplicatesWithinDialogOnly() + { + var services = new ServiceCollection(); + services.AddPage("main"); + services.AddContent("main"); + services.AddWindow("main"); + services.AddWindow("Main"); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + var exception = Assert.Throws( + () => services.AddWindow("main")); + + Assert.Equal("key", exception.ParamName); + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "main")); + Assert.Equal( + typeof(SecondWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "Main")); + } + + [Fact] + public void DialogRoutes_RegisteredBeforeAndAfterCoreRegistration_AreAvailable() + { + var services = new ServiceCollection(); + services.AddWindow("first"); + services.RegisterNavigationService(); + services.AddWindow("second"); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "first")); + Assert.Equal( + typeof(SecondWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "second")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void DialogRoutes_InvalidKeysThrowArgumentException(string? key) + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredDialogType", key)); + } + + [Fact] + public void DialogRoutes_UnknownKeyThrowsKeyNotFoundException() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredDialogType", "missing")); + } + private static object GetRouteRegistry( IServiceCollection services, IServiceProvider provider) diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index 0765008..ff4921a 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -1,5 +1,6 @@ using SimpleNavigation.Common; using SimpleNavigation.Interface.Awares; +using System.Windows; using System.Windows.Controls; namespace SimpleNavigation.Tests; @@ -12,6 +13,14 @@ public sealed class SecondPage : Page { } +public sealed class FirstWindow : Window +{ +} + +public sealed class SecondWindow : Window +{ +} + public sealed class TestContent : UserControl { } @@ -20,6 +29,10 @@ public sealed class TestViewModel { } +public sealed class DialogViewModel +{ +} + public sealed class AwareViewModel : INavigationAware { public int CallCount { get; private set; } From 5803eafe74d0cc3a8831f75b23f9d229a94d8fa0 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 20:51:25 +0800 Subject: [PATCH 25/42] test: harden dialog registration validation --- .../NavigationExtensionsTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index 9c6c045..16f61bf 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -429,6 +429,55 @@ public void DialogRoutes_UnknownKeyThrowsKeyNotFoundException() () => InvokeRouteLookup(registry, "GetRequiredDialogType", "missing")); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void DialogRegistration_InvalidKeyDoesNotModifyServiceCollection(string? key) + { + var services = new ServiceCollection(); + var originalDescriptors = services.ToArray(); + + var windowException = Assert.Throws( + () => services.AddWindow(key!)); + + Assert.Equal("key", windowException.ParamName); + Assert.Equal(originalDescriptors.Length, services.Count); + Assert.True(originalDescriptors.SequenceEqual(services)); + + var viewModelException = Assert.Throws( + () => services.AddWindow(key!)); + + Assert.Equal("key", viewModelException.ParamName); + Assert.Equal(originalDescriptors.Length, services.Count); + Assert.True(originalDescriptors.SequenceEqual(services)); + } + + [Fact] + public void DialogRegistration_DuplicateKeyDoesNotAddDescriptorsOrReplaceRoute() + { + var services = new ServiceCollection(); + services.AddWindow("main"); + var originalDescriptors = services.ToArray(); + + var exception = Assert.Throws( + () => services.AddWindow("main")); + + Assert.Equal("key", exception.ParamName); + Assert.Equal(originalDescriptors.Length, services.Count); + Assert.True(originalDescriptors.SequenceEqual(services)); + Assert.DoesNotContain(services, item => item.ServiceType == typeof(SecondWindow)); + Assert.DoesNotContain(services, item => item.ServiceType == typeof(DialogViewModel)); + + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "main")); + } + private static object GetRouteRegistry( IServiceCollection services, IServiceProvider provider) From 20dc5ec9a5538beba019bf8946d5f91b458eec7a Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 21:01:22 +0800 Subject: [PATCH 26/42] refactor: manage dialog windows by type --- Common/Managers/DialogManager.cs | 111 +++++++++-- Interface/Managers/IDialogManager.cs | 14 ++ .../DialogManagerTests.cs | 178 ++++++++++++++++++ 3 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 Tests/SimpleNavigation.Tests/DialogManagerTests.cs diff --git a/Common/Managers/DialogManager.cs b/Common/Managers/DialogManager.cs index 863dbbd..cdfcb68 100644 --- a/Common/Managers/DialogManager.cs +++ b/Common/Managers/DialogManager.cs @@ -7,36 +7,119 @@ namespace SimpleNavigation.Common.Managers public class DialogManager : IDialogManager { private readonly IServiceProvider provider; - private Dictionary> dialogWindows = new(); + private readonly object syncRoot = new(); + private readonly Dictionary> dialogWindows = new(); public DialogManager(IServiceProvider provider) { - this.provider = provider; + this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); } private void OnWindowClosed(object? sender, EventArgs e) { - if (sender == null) return; + if (sender is not Window closedWindow) return; - var type = sender.GetType(); - if (dialogWindows.ContainsKey(type)) - dialogWindows.Remove(type); + lock (syncRoot) + { + Type? matchingType = null; + + foreach (var dialogWindow in dialogWindows) + { + if (dialogWindow.Value.TryGetTarget(out var cachedWindow) + && ReferenceEquals(cachedWindow, closedWindow)) + { + matchingType = dialogWindow.Key; + break; + } + } + + if (matchingType != null) + { + dialogWindows.Remove(matchingType); + } + } + } + + public Window GetOrCreateWindow(Type windowType) + { + ValidateWindowType(windowType); + + var existingWindow = GetExistingWindowCore(windowType); + if (existingWindow != null) + { + return existingWindow; + } + + var service = provider.GetRequiredService(windowType); + if (service is not Window newWindow) + { + throw new InvalidOperationException( + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"'{service.GetType().FullName}', which is not a '{typeof(Window).FullName}'."); + } + + lock (syncRoot) + { + if (dialogWindows.TryGetValue(windowType, out var weakWindow)) + { + if (weakWindow.TryGetTarget(out existingWindow)) + { + return existingWindow; + } + + dialogWindows.Remove(windowType); + } + + dialogWindows[windowType] = new WeakReference(newWindow); + } + + newWindow.Closed += OnWindowClosed; + return newWindow; + } + + public Window? GetExistingWindow(Type windowType) + { + ValidateWindowType(windowType); + return GetExistingWindowCore(windowType); } public T? GetDialogWindow() where T : Window { - if (dialogWindows.ContainsKey(typeof(T))) - return dialogWindows[typeof(T)].TryGetTarget(out var window) ? window as T : null; + return (T)GetOrCreateWindow(typeof(T)); + } - var weakWindow = new WeakReference(provider.GetRequiredService()); - dialogWindows[typeof(T)] = weakWindow; + private Window? GetExistingWindowCore(Type windowType) + { + lock (syncRoot) + { + if (!dialogWindows.TryGetValue(windowType, out var weakWindow)) + { + return null; + } + + if (weakWindow.TryGetTarget(out var window)) + { + return window; + } - var getResult = weakWindow.TryGetTarget(out var w); - if (!getResult || w == null) return null; + dialogWindows.Remove(windowType); + return null; + } + } - w.Closed += OnWindowClosed; + private static void ValidateWindowType(Type windowType) + { + if (windowType == null) + { + throw new ArgumentNullException(nameof(windowType)); + } - return w as T; + if (!typeof(Window).IsAssignableFrom(windowType)) + { + throw new ArgumentException( + $"Type '{windowType.FullName}' must derive from '{typeof(Window).FullName}'.", + nameof(windowType)); + } } } } diff --git a/Interface/Managers/IDialogManager.cs b/Interface/Managers/IDialogManager.cs index 9d0801c..fec381e 100644 --- a/Interface/Managers/IDialogManager.cs +++ b/Interface/Managers/IDialogManager.cs @@ -7,6 +7,20 @@ namespace SimpleNavigation.Interface.Managers /// public interface IDialogManager { + /// + /// èŽ·å–æˆ–åˆ›å»ºæŒ‡å®šç±»åž‹çš„å¯¹è¯æ¡†çª—å£å®žä¾‹ + /// + /// å¯¹è¯æ¡†çª—å£ç±»åž‹ + /// å¯¹è¯æ¡†çª—å£å®žä¾‹ + Window GetOrCreateWindow(Type windowType); + + /// + /// 获å–已创建且ä»å­˜æ´»çš„æŒ‡å®šç±»åž‹å¯¹è¯æ¡†çª—å£å®žä¾‹ + /// + /// å¯¹è¯æ¡†çª—å£ç±»åž‹ + /// 现有窗å£å®žä¾‹ï¼›å¦‚æžœä¸å­˜åœ¨åˆ™è¿”回 + Window? GetExistingWindow(Type windowType); + /// /// èŽ·å–æŒ‡å®šç±»åž‹çš„å¯¹è¯æ¡†çª—å£å®žä¾‹ /// diff --git a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs new file mode 100644 index 0000000..478095b --- /dev/null +++ b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs @@ -0,0 +1,178 @@ +using System.Reflection; +using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common.Managers; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class DialogManagerTests +{ + [Fact] + public void GetExistingWindow_BeforeCreation_ReturnsNullWithoutResolvingService() + { + StaTest.Run(() => + { + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + return new ManagedWindow(); + }) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Null(manager.GetExistingWindow(typeof(ManagedWindow))); + Assert.Equal(0, resolutionCount); + }); + } + + [Fact] + public void GetOrCreateWindow_LiveWindow_ResolvesOnceAndReusesExactInstance() + { + StaTest.Run(() => + { + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + return new ManagedWindow(); + }) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + + var first = manager.GetOrCreateWindow(typeof(ManagedWindow)); + var second = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + Assert.IsType(first); + Assert.Same(first, second); + Assert.Same(first, manager.GetExistingWindow(typeof(ManagedWindow))); + Assert.Same(first, manager.GetDialogWindow()); + Assert.Equal(1, resolutionCount); + + first.Close(); + }); + } + + [Fact] + public void GetOrCreateWindow_AfterShownWindowCloses_ResolvesNewTransientInstance() + { + StaTest.Run(() => + { + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + return new ManagedWindow(); + }) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + var first = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + first.Show(); + StaTest.PumpDispatcher(); + first.Close(); + StaTest.PumpDispatcher(); + + Assert.Null(manager.GetExistingWindow(typeof(ManagedWindow))); + + var second = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + Assert.NotSame(first, second); + Assert.Equal(2, resolutionCount); + second.Close(); + }); + } + + [Fact] + public void ClosedNotification_FromOlderWindow_DoesNotRemoveNewerReplacement() + { + StaTest.Run(() => + { + using var provider = new ServiceCollection() + .AddTransient() + .BuildServiceProvider(); + var manager = new DialogManager(provider); + var first = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + first.Show(); + StaTest.PumpDispatcher(); + first.Close(); + StaTest.PumpDispatcher(); + var replacement = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + var closeCallback = typeof(DialogManager).GetMethod( + "OnWindowClosed", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(closeCallback); + closeCallback!.Invoke(manager, new object?[] { first, EventArgs.Empty }); + + Assert.Same(replacement, manager.GetExistingWindow(typeof(ManagedWindow))); + replacement.Close(); + }); + } + + [Fact] + public void WindowType_Null_ThrowsArgumentNullException() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetExistingWindow(null!)).ParamName); + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetOrCreateWindow(null!)).ParamName); + } + + [Fact] + public void WindowType_NonWindow_ThrowsArgumentException() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetExistingWindow(typeof(string))).ParamName); + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetOrCreateWindow(typeof(string))).ParamName); + } + + [Fact] + public void GetOrCreateWindow_MissingRegistration_PreservesGetRequiredServiceException() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var manager = new DialogManager(provider); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(ManagedWindow))); + + Assert.Contains(typeof(ManagedWindow).ToString(), exception.Message); + } + + [Fact] + public void GetOrCreateWindow_ProviderReturnsNonWindow_ThrowsClearInvalidOperationException() + { + var manager = new DialogManager(new WrongTypeServiceProvider()); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(ManagedWindow))); + + Assert.Contains(typeof(ManagedWindow).FullName!, exception.Message); + Assert.Contains(typeof(string).FullName!, exception.Message); + } + + private sealed class ManagedWindow : Window + { + } + + private sealed class WrongTypeServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => "not a window"; + } +} From e4c212b88fa5f0e9dd43b58ea9eefd44a3d34eea Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 21:19:09 +0800 Subject: [PATCH 27/42] fix: harden dialog window lifecycle --- Common/Managers/DialogManager.cs | 100 ++++---- .../DialogManagerTests.cs | 220 ++++++++++++++++++ 2 files changed, 274 insertions(+), 46 deletions(-) diff --git a/Common/Managers/DialogManager.cs b/Common/Managers/DialogManager.cs index cdfcb68..06aeca5 100644 --- a/Common/Managers/DialogManager.cs +++ b/Common/Managers/DialogManager.cs @@ -9,6 +9,7 @@ public class DialogManager : IDialogManager private readonly IServiceProvider provider; private readonly object syncRoot = new(); private readonly Dictionary> dialogWindows = new(); + private readonly HashSet resolvingTypes = new(); public DialogManager(IServiceProvider provider) { @@ -19,23 +20,16 @@ private void OnWindowClosed(object? sender, EventArgs e) { if (sender is not Window closedWindow) return; + closedWindow.Closed -= OnWindowClosed; + lock (syncRoot) { - Type? matchingType = null; - - foreach (var dialogWindow in dialogWindows) - { - if (dialogWindow.Value.TryGetTarget(out var cachedWindow) - && ReferenceEquals(cachedWindow, closedWindow)) - { - matchingType = dialogWindow.Key; - break; - } - } - - if (matchingType != null) + var windowType = closedWindow.GetType(); + if (dialogWindows.TryGetValue(windowType, out var weakWindow) + && weakWindow.TryGetTarget(out var cachedWindow) + && ReferenceEquals(cachedWindow, closedWindow)) { - dialogWindows.Remove(matchingType); + dialogWindows.Remove(windowType); } } } @@ -44,37 +38,46 @@ public Window GetOrCreateWindow(Type windowType) { ValidateWindowType(windowType); - var existingWindow = GetExistingWindowCore(windowType); - if (existingWindow != null) + lock (syncRoot) { - return existingWindow; - } + var existingWindow = GetExistingWindowLocked(windowType); + if (existingWindow != null) + { + return existingWindow; + } - var service = provider.GetRequiredService(windowType); - if (service is not Window newWindow) - { - throw new InvalidOperationException( - $"The service registered for window type '{windowType.FullName}' resolved to " + - $"'{service.GetType().FullName}', which is not a '{typeof(Window).FullName}'."); - } + if (!resolvingTypes.Add(windowType)) + { + throw new InvalidOperationException( + $"Window type '{windowType.FullName}' is already being resolved by this dialog manager."); + } - lock (syncRoot) - { - if (dialogWindows.TryGetValue(windowType, out var weakWindow)) + try { - if (weakWindow.TryGetTarget(out existingWindow)) + var service = provider.GetRequiredService(windowType); + if (service is not Window newWindow) { - return existingWindow; + throw new InvalidOperationException( + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"'{service.GetType().FullName}', which is not a '{typeof(Window).FullName}'."); } - dialogWindows.Remove(windowType); - } + if (newWindow.GetType() != windowType) + { + throw new InvalidOperationException( + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"the different window type '{newWindow.GetType().FullName}'."); + } - dialogWindows[windowType] = new WeakReference(newWindow); + newWindow.Closed += OnWindowClosed; + dialogWindows[windowType] = new WeakReference(newWindow); + return newWindow; + } + finally + { + resolvingTypes.Remove(windowType); + } } - - newWindow.Closed += OnWindowClosed; - return newWindow; } public Window? GetExistingWindow(Type windowType) @@ -92,19 +95,24 @@ public Window GetOrCreateWindow(Type windowType) { lock (syncRoot) { - if (!dialogWindows.TryGetValue(windowType, out var weakWindow)) - { - return null; - } - - if (weakWindow.TryGetTarget(out var window)) - { - return window; - } + return GetExistingWindowLocked(windowType); + } + } - dialogWindows.Remove(windowType); + private Window? GetExistingWindowLocked(Type windowType) + { + if (!dialogWindows.TryGetValue(windowType, out var weakWindow)) + { return null; } + + if (weakWindow.TryGetTarget(out var window)) + { + return window; + } + + dialogWindows.Remove(windowType); + return null; } private static void ValidateWindowType(Type windowType) diff --git a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs index 478095b..8725e9b 100644 --- a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs @@ -1,4 +1,6 @@ using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; using System.Windows; using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common.Managers; @@ -167,12 +169,230 @@ public void GetOrCreateWindow_ProviderReturnsNonWindow_ThrowsClearInvalidOperati Assert.Contains(typeof(string).FullName!, exception.Message); } + [Fact] + public void GetOrCreateWindow_SameTypeFactoryReentry_ThrowsAndCanRetry() + { + StaTest.Run(() => + { + DialogManager? manager = null; + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + if (resolutionCount == 1) + { + return (ReentrantWindow)manager!.GetOrCreateWindow(typeof(ReentrantWindow)); + } + + return new ReentrantWindow(); + }) + .BuildServiceProvider(); + manager = new DialogManager(provider); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(ReentrantWindow))); + + Assert.Contains(typeof(ReentrantWindow).FullName!, exception.Message); + Assert.Contains("already being resolved", exception.Message); + Assert.Equal(1, resolutionCount); + Assert.Null(manager.GetExistingWindow(typeof(ReentrantWindow))); + + var retry = manager.GetOrCreateWindow(typeof(ReentrantWindow)); + + Assert.IsType(retry); + Assert.Equal(2, resolutionCount); + retry.Close(); + }); + } + + [Fact] + public void GetOrCreateWindow_ConcurrentCallers_ResolveOneTransientInstance() + { + StaTest.Run(() => + { + var provider = new CoordinatedServiceProvider(); + var manager = new DialogManager(provider); + Window? firstResult = null; + Window? secondResult = null; + Exception? firstException = null; + Exception? secondException = null; + using var secondCallStarted = new ManualResetEventSlim(); + + var firstThread = CreateStaThread(() => + { + try + { + firstResult = manager.GetOrCreateWindow(typeof(ConcurrentWindow)); + } + catch (Exception exception) + { + firstException = exception; + } + }); + var secondThread = CreateStaThread(() => + { + secondCallStarted.Set(); + try + { + secondResult = manager.GetOrCreateWindow(typeof(ConcurrentWindow)); + } + catch (Exception exception) + { + secondException = exception; + } + }); + + firstThread.Start(); + try + { + Assert.True(provider.FirstResolutionEntered.Wait(TimeSpan.FromSeconds(5))); + secondThread.Start(); + Assert.True(secondCallStarted.Wait(TimeSpan.FromSeconds(5))); + Assert.True( + SpinWait.SpinUntil( + () => Volatile.Read(ref provider.ResolutionCount) > 1 + || (secondThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)), + "The second caller neither entered DI resolution nor waited for the first caller."); + } + finally + { + provider.ReleaseFirstResolution.Set(); + Assert.True(firstThread.Join(TimeSpan.FromSeconds(5))); + if (secondThread.ThreadState != ThreadState.Unstarted) + { + Assert.True(secondThread.Join(TimeSpan.FromSeconds(5))); + } + } + + Assert.Null(firstException); + Assert.Null(secondException); + Assert.Equal(1, provider.ResolutionCount); + Assert.NotNull(firstResult); + Assert.Same(firstResult, secondResult); + GC.KeepAlive(firstResult); + }); + } + + [Fact] + public void GetOrCreateWindow_RegistrationReturnsDifferentWindowType_ThrowsWithoutCaching() + { + StaTest.Run(() => + { + using var provider = new ServiceCollection() + .AddTransient(typeof(FirstWindow), _ => new SecondWindow()) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(FirstWindow))); + + Assert.Contains(typeof(FirstWindow).FullName!, exception.Message); + Assert.Contains(typeof(SecondWindow).FullName!, exception.Message); + Assert.Null(manager.GetExistingWindow(typeof(FirstWindow))); + }); + } + + [Fact] + public void ClosedWindow_RetainedByCaller_DoesNotKeepManagerAlive() + { + StaTest.Run(() => + { + var retainedWindow = new ManagedWindow(); + var weakManager = CreateAndCloseManager(retainedWindow); + + ForceGarbageCollection(weakManager); + + Assert.False(weakManager.IsAlive); + GC.KeepAlive(retainedWindow); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAndCloseManager(ManagedWindow window) + { + var manager = new DialogManager(new FixedWindowServiceProvider(window)); + Assert.Same(window, manager.GetOrCreateWindow(typeof(ManagedWindow))); + + window.Show(); + StaTest.PumpDispatcher(); + window.Close(); + StaTest.PumpDispatcher(); + + return new WeakReference(manager); + } + + private static void ForceGarbageCollection(WeakReference weakReference) + { + const int attemptLimit = 10; + + for (var attempt = 0; attempt < attemptLimit && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private static Thread CreateStaThread(ThreadStart action) + { + var thread = new Thread(action) + { + IsBackground = true, + }; + thread.SetApartmentState(ApartmentState.STA); + return thread; + } + private sealed class ManagedWindow : Window { } + private sealed class ReentrantWindow : Window + { + } + + private sealed class ConcurrentWindow : Window + { + } + private sealed class WrongTypeServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => "not a window"; } + + private sealed class FixedWindowServiceProvider : IServiceProvider + { + private readonly Window window; + + public FixedWindowServiceProvider(Window window) + { + this.window = window; + } + + public object? GetService(Type serviceType) => window; + } + + private sealed class CoordinatedServiceProvider : IServiceProvider + { + public readonly ManualResetEventSlim FirstResolutionEntered = new(); + public readonly ManualResetEventSlim ReleaseFirstResolution = new(); + public int ResolutionCount; + + public object? GetService(Type serviceType) + { + var resolution = Interlocked.Increment(ref ResolutionCount); + if (resolution == 1) + { + FirstResolutionEntered.Set(); + if (!ReleaseFirstResolution.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The first resolution was not released by the test."); + } + } + + return new ConcurrentWindow(); + } + } } From 46377799b222a769685bee371dcce358b6546ff0 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 21:33:07 +0800 Subject: [PATCH 28/42] fix: avoid dialog resolution lock inversion --- Common/Managers/DialogManager.cs | 120 ++++++-- .../DialogManagerTests.cs | 260 +++++++++++++++--- 2 files changed, 320 insertions(+), 60 deletions(-) diff --git a/Common/Managers/DialogManager.cs b/Common/Managers/DialogManager.cs index 06aeca5..a437e44 100644 --- a/Common/Managers/DialogManager.cs +++ b/Common/Managers/DialogManager.cs @@ -1,3 +1,4 @@ +using System.Runtime.ExceptionServices; using System.Windows; using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Interface.Managers; @@ -9,7 +10,7 @@ public class DialogManager : IDialogManager private readonly IServiceProvider provider; private readonly object syncRoot = new(); private readonly Dictionary> dialogWindows = new(); - private readonly HashSet resolvingTypes = new(); + private readonly Dictionary resolutionStates = new(); public DialogManager(IServiceProvider provider) { @@ -38,45 +39,99 @@ public Window GetOrCreateWindow(Type windowType) { ValidateWindowType(windowType); - lock (syncRoot) + while (true) { - var existingWindow = GetExistingWindowLocked(windowType); - if (existingWindow != null) + ResolutionState resolutionState; + var ownsResolution = false; + + lock (syncRoot) + { + var existingWindow = GetExistingWindowLocked(windowType); + if (existingWindow != null) + { + return existingWindow; + } + + if (resolutionStates.TryGetValue(windowType, out var activeResolutionState)) + { + resolutionState = activeResolutionState; + if (resolutionState.OwnerThreadId == Environment.CurrentManagedThreadId) + { + throw new InvalidOperationException( + $"Window type '{windowType.FullName}' is already being resolved by this dialog manager."); + } + } + else + { + resolutionState = new ResolutionState(Environment.CurrentManagedThreadId); + resolutionStates.Add(windowType, resolutionState); + ownsResolution = true; + } + } + + if (ownsResolution) { - return existingWindow; + return ResolveAndPublishWindow(windowType, resolutionState); } - if (!resolvingTypes.Add(windowType)) + resolutionState.Completion.Task.GetAwaiter().GetResult(); + if (resolutionState.ResolutionException != null) + { + resolutionState.ResolutionException.Throw(); + } + } + } + + private Window ResolveAndPublishWindow(Type windowType, ResolutionState resolutionState) + { + try + { + var service = provider.GetRequiredService(windowType); + if (service is not Window newWindow) { throw new InvalidOperationException( - $"Window type '{windowType.FullName}' is already being resolved by this dialog manager."); + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"'{service.GetType().FullName}', which is not a '{typeof(Window).FullName}'."); } - try + if (newWindow.GetType() != windowType) { - var service = provider.GetRequiredService(windowType); - if (service is not Window newWindow) - { - throw new InvalidOperationException( - $"The service registered for window type '{windowType.FullName}' resolved to " + - $"'{service.GetType().FullName}', which is not a '{typeof(Window).FullName}'."); - } + throw new InvalidOperationException( + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"the different window type '{newWindow.GetType().FullName}'."); + } - if (newWindow.GetType() != windowType) - { - throw new InvalidOperationException( - $"The service registered for window type '{windowType.FullName}' resolved to " + - $"the different window type '{newWindow.GetType().FullName}'."); - } + newWindow.Closed += OnWindowClosed; - newWindow.Closed += OnWindowClosed; + lock (syncRoot) + { dialogWindows[windowType] = new WeakReference(newWindow); - return newWindow; + RemoveResolutionStateLocked(windowType, resolutionState); } - finally + + resolutionState.Completion.TrySetResult(true); + return newWindow; + } + catch (Exception exception) + { + resolutionState.ResolutionException = ExceptionDispatchInfo.Capture(exception); + + lock (syncRoot) { - resolvingTypes.Remove(windowType); + RemoveResolutionStateLocked(windowType, resolutionState); } + + resolutionState.Completion.TrySetResult(true); + throw; + } + } + + private void RemoveResolutionStateLocked(Type windowType, ResolutionState resolutionState) + { + if (resolutionStates.TryGetValue(windowType, out var currentState) + && ReferenceEquals(currentState, resolutionState)) + { + resolutionStates.Remove(windowType); } } @@ -129,5 +184,20 @@ private static void ValidateWindowType(Type windowType) nameof(windowType)); } } + + private sealed class ResolutionState + { + public ResolutionState(int ownerThreadId) + { + OwnerThreadId = ownerThreadId; + Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public int OwnerThreadId { get; } + + public TaskCompletionSource Completion { get; } + + public ExceptionDispatchInfo? ResolutionException { get; set; } + } } } diff --git a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs index 8725e9b..d76198c 100644 --- a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Windows; +using System.Windows.Threading; using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common.Managers; using SimpleNavigation.Tests.TestInfrastructure; @@ -207,71 +208,198 @@ public void GetOrCreateWindow_SameTypeFactoryReentry_ThrowsAndCanRetry() } [Fact] - public void GetOrCreateWindow_ConcurrentCallers_ResolveOneTransientInstance() + public void GetOrCreateWindow_BlockedType_DoesNotBlockUnrelatedOperationsAndResolvesOnce() { StaTest.Run(() => { - var provider = new CoordinatedServiceProvider(); + using var provider = new MultiTypeCoordinatedServiceProvider(); var manager = new DialogManager(provider); - Window? firstResult = null; - Window? secondResult = null; - Exception? firstException = null; - Exception? secondException = null; - using var secondCallStarted = new ManualResetEventSlim(); - - var firstThread = CreateStaThread(() => + Window? ownerResult = null; + Window? waiterResult = null; + Window? existingResult = null; + Window? unrelatedResult = null; + Exception? ownerException = null; + Exception? waiterException = null; + Exception? existingException = null; + Exception? unrelatedException = null; + using var waiterStarted = new ManualResetEventSlim(); + using var existingCompleted = new ManualResetEventSlim(); + using var unrelatedCompleted = new ManualResetEventSlim(); + using var ownerMayClose = new ManualResetEventSlim(); + + var ownerThread = CreateStaThread(() => + { + try + { + ownerResult = manager.GetOrCreateWindow(typeof(FirstWindow)); + ownerMayClose.Wait(TimeSpan.FromSeconds(10)); + ownerResult.Close(); + } + catch (Exception exception) + { + ownerException = exception; + } + }); + var waiterThread = CreateStaThread(() => { + waiterStarted.Set(); try { - firstResult = manager.GetOrCreateWindow(typeof(ConcurrentWindow)); + waiterResult = manager.GetOrCreateWindow(typeof(FirstWindow)); } catch (Exception exception) { - firstException = exception; + waiterException = exception; } }); - var secondThread = CreateStaThread(() => + var existingThread = CreateStaThread(() => { - secondCallStarted.Set(); try { - secondResult = manager.GetOrCreateWindow(typeof(ConcurrentWindow)); + existingResult = manager.GetExistingWindow(typeof(FirstWindow)); } catch (Exception exception) { - secondException = exception; + existingException = exception; + } + finally + { + existingCompleted.Set(); + } + }); + var unrelatedThread = CreateStaThread(() => + { + try + { + unrelatedResult = manager.GetOrCreateWindow(typeof(SecondWindow)); + unrelatedResult.Close(); + } + catch (Exception exception) + { + unrelatedException = exception; + } + finally + { + unrelatedCompleted.Set(); } }); - firstThread.Start(); + var existingMadeProgress = false; + var unrelatedMadeProgress = false; + ownerThread.Start(); try { Assert.True(provider.FirstResolutionEntered.Wait(TimeSpan.FromSeconds(5))); - secondThread.Start(); - Assert.True(secondCallStarted.Wait(TimeSpan.FromSeconds(5))); + waiterThread.Start(); + Assert.True(waiterStarted.Wait(TimeSpan.FromSeconds(5))); Assert.True( SpinWait.SpinUntil( - () => Volatile.Read(ref provider.ResolutionCount) > 1 - || (secondThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + () => (waiterThread.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(5)), - "The second caller neither entered DI resolution nor waited for the first caller."); + "The same-type caller did not wait for the in-flight resolution."); + + existingThread.Start(); + unrelatedThread.Start(); + existingMadeProgress = existingCompleted.Wait(TimeSpan.FromSeconds(2)); + unrelatedMadeProgress = unrelatedCompleted.Wait(TimeSpan.FromSeconds(2)); } finally { provider.ReleaseFirstResolution.Set(); - Assert.True(firstThread.Join(TimeSpan.FromSeconds(5))); - if (secondThread.ThreadState != ThreadState.Unstarted) + JoinIfStarted(waiterThread); + JoinIfStarted(existingThread); + JoinIfStarted(unrelatedThread); + ownerMayClose.Set(); + JoinIfStarted(ownerThread); + } + + Assert.True(existingMadeProgress, "GetExistingWindow blocked behind unrelated DI resolution."); + Assert.True(unrelatedMadeProgress, "A different window type blocked behind unrelated DI resolution."); + Assert.Null(ownerException); + Assert.Null(waiterException); + Assert.Null(existingException); + Assert.Null(unrelatedException); + Assert.Null(existingResult); + Assert.Equal(1, provider.FirstResolutionCount); + Assert.NotNull(ownerResult); + Assert.Same(ownerResult, waiterResult); + Assert.IsType(unrelatedResult); + GC.KeepAlive(ownerResult); + }); + } + + [Fact] + public void GetOrCreateWindow_FailedResolution_WakesWaitersAndLaterCallRetries() + { + StaTest.Run(() => + { + using var provider = new FailingThenSuccessfulServiceProvider(); + var manager = new DialogManager(provider); + Exception? ownerException = null; + Exception? waiterException = null; + Window? waiterResult = null; + using var waiterStarted = new ManualResetEventSlim(); + + var ownerThread = CreateStaThread(() => + { + try + { + manager.GetOrCreateWindow(typeof(FailureWindow)); + } + catch (Exception exception) + { + ownerException = exception; + } + }); + var waiterThread = CreateStaThread(() => + { + waiterStarted.Set(); + try { - Assert.True(secondThread.Join(TimeSpan.FromSeconds(5))); + waiterResult = manager.GetOrCreateWindow(typeof(FailureWindow)); } + catch (Exception exception) + { + waiterException = exception; + } + finally + { + if (waiterResult != null) + { + waiterResult.Close(); + } + } + }); + + ownerThread.Start(); + try + { + Assert.True(provider.FirstResolutionEntered.Wait(TimeSpan.FromSeconds(5))); + waiterThread.Start(); + Assert.True(waiterStarted.Wait(TimeSpan.FromSeconds(5))); + Assert.True( + SpinWait.SpinUntil( + () => (waiterThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)), + "The waiter did not wait for the failing in-flight resolution."); + } + finally + { + provider.ReleaseFirstResolution.Set(); + JoinIfStarted(ownerThread); + JoinIfStarted(waiterThread); } - Assert.Null(firstException); - Assert.Null(secondException); + Assert.Same(provider.ExpectedFailure, ownerException); + Assert.Same(provider.ExpectedFailure, waiterException); + Assert.Null(waiterResult); Assert.Equal(1, provider.ResolutionCount); - Assert.NotNull(firstResult); - Assert.Same(firstResult, secondResult); - GC.KeepAlive(firstResult); + + var retry = manager.GetOrCreateWindow(typeof(FailureWindow)); + + Assert.IsType(retry); + Assert.Equal(2, provider.ResolutionCount); + retry.Close(); }); } @@ -337,7 +465,17 @@ private static void ForceGarbageCollection(WeakReference weakReference) private static Thread CreateStaThread(ThreadStart action) { - var thread = new Thread(action) + var thread = new Thread(() => + { + try + { + action(); + } + finally + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + }) { IsBackground = true, }; @@ -345,6 +483,14 @@ private static Thread CreateStaThread(ThreadStart action) return thread; } + private static void JoinIfStarted(Thread thread) + { + if (thread.ThreadState != ThreadState.Unstarted) + { + Assert.True(thread.Join(TimeSpan.FromSeconds(10)), "An STA worker did not finish."); + } + } + private sealed class ManagedWindow : Window { } @@ -353,7 +499,7 @@ private sealed class ReentrantWindow : Window { } - private sealed class ConcurrentWindow : Window + private sealed class FailureWindow : Window { } @@ -374,10 +520,46 @@ public FixedWindowServiceProvider(Window window) public object? GetService(Type serviceType) => window; } - private sealed class CoordinatedServiceProvider : IServiceProvider + private sealed class MultiTypeCoordinatedServiceProvider : IServiceProvider, IDisposable + { + public readonly ManualResetEventSlim FirstResolutionEntered = new(); + public readonly ManualResetEventSlim ReleaseFirstResolution = new(); + public int FirstResolutionCount; + + public object? GetService(Type serviceType) + { + if (serviceType == typeof(FirstWindow)) + { + Interlocked.Increment(ref FirstResolutionCount); + FirstResolutionEntered.Set(); + if (!ReleaseFirstResolution.Wait(TimeSpan.FromSeconds(15))) + { + throw new TimeoutException("The first resolution was not released by the test."); + } + + return new FirstWindow(); + } + + if (serviceType == typeof(SecondWindow)) + { + return new SecondWindow(); + } + + return null; + } + + public void Dispose() + { + FirstResolutionEntered.Dispose(); + ReleaseFirstResolution.Dispose(); + } + } + + private sealed class FailingThenSuccessfulServiceProvider : IServiceProvider, IDisposable { public readonly ManualResetEventSlim FirstResolutionEntered = new(); public readonly ManualResetEventSlim ReleaseFirstResolution = new(); + public readonly InvalidOperationException ExpectedFailure = new("expected resolution failure"); public int ResolutionCount; public object? GetService(Type serviceType) @@ -386,13 +568,21 @@ private sealed class CoordinatedServiceProvider : IServiceProvider if (resolution == 1) { FirstResolutionEntered.Set(); - if (!ReleaseFirstResolution.Wait(TimeSpan.FromSeconds(5))) + if (!ReleaseFirstResolution.Wait(TimeSpan.FromSeconds(15))) { - throw new TimeoutException("The first resolution was not released by the test."); + throw new TimeoutException("The failing resolution was not released by the test."); } + + throw ExpectedFailure; } - return new ConcurrentWindow(); + return new FailureWindow(); + } + + public void Dispose() + { + FirstResolutionEntered.Dispose(); + ReleaseFirstResolution.Dispose(); } } } From bd13519f7492b8242e8c2f12af141f4fde005e4b Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 21:39:17 +0800 Subject: [PATCH 29/42] test: harden dialog manager thread cleanup --- Tests/SimpleNavigation.Tests/DialogManagerTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs index d76198c..0bd5d5d 100644 --- a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs @@ -437,6 +437,16 @@ public void ClosedWindow_RetainedByCaller_DoesNotKeepManagerAlive() }); } + [Fact] + public void JoinIfStarted_UnstartedBackgroundThread_DoesNothing() + { + var thread = CreateStaThread(() => { }); + + JoinIfStarted(thread); + + Assert.True((thread.ThreadState & ThreadState.Unstarted) != 0); + } + [MethodImpl(MethodImplOptions.NoInlining)] private static WeakReference CreateAndCloseManager(ManagedWindow window) { @@ -485,7 +495,7 @@ private static Thread CreateStaThread(ThreadStart action) private static void JoinIfStarted(Thread thread) { - if (thread.ThreadState != ThreadState.Unstarted) + if ((thread.ThreadState & ThreadState.Unstarted) == 0) { Assert.True(thread.Join(TimeSpan.FromSeconds(10)), "An STA worker did not finish."); } From fbcc204db25d58b0ad801b08363edd280ed2d62c Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 21:58:31 +0800 Subject: [PATCH 30/42] feat: rebuild dialog service around keyed windows --- Interface/Services/IDialogService.cs | 22 +- Services/DialogService.cs | 278 +++++++++-- .../DialogServiceTests.cs | 450 ++++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 96 ++++ 4 files changed, 793 insertions(+), 53 deletions(-) create mode 100644 Tests/SimpleNavigation.Tests/DialogServiceTests.cs diff --git a/Interface/Services/IDialogService.cs b/Interface/Services/IDialogService.cs index c6c6b1c..7567a90 100644 --- a/Interface/Services/IDialogService.cs +++ b/Interface/Services/IDialogService.cs @@ -11,16 +11,30 @@ public interface IDialogService /// /// 显示一个新Dialog /// - /// Dialog类型 + /// Dialog类型 /// Dialog傿•° - public void Show(DialogParameters? parameters = null) where T : Window; + public void Show(DialogParameters? parameters = null) where TWindow : Window; + + public void Show(Type targetType, DialogParameters? parameters = null); + + public void Show(string key, DialogParameters? parameters = null); /// /// 显示一个新Dialog并等待用户关闭 /// - /// Dialog类型 + /// Dialog类型 /// Dialog傿•° /// Dialogå…³é—­æ—¶è¿”å›žçš„å‚æ•° - public DialogParameters? ShowDialog(DialogParameters? parameters = null) where T : Window; + public DialogParameters? ShowDialog(DialogParameters? parameters = null) where TWindow : Window; + + public DialogParameters? ShowDialog(Type targetType, DialogParameters? parameters = null); + + public DialogParameters? ShowDialog(string key, DialogParameters? parameters = null); + + public bool Close() where TWindow : Window; + + public bool Close(Type targetType); + + public bool Close(string key); } } diff --git a/Services/DialogService.cs b/Services/DialogService.cs index b58c3d4..d2864ae 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; using SimpleNavigation.Interface.Awares; using SimpleNavigation.Interface.Managers; @@ -12,88 +13,267 @@ namespace SimpleNavigation.Services public class DialogService : IDialogService { private readonly IDialogManager dialogManager; + private readonly NavigationRouteRegistry routes; + private readonly object subscriptionSyncRoot = new(); + private readonly Dictionary nonModalSubscriptions = new(); - public DialogService(IDialogManager dialogManager) + public DialogService(IServiceProvider provider, IDialogManager dialogManager) { - this.dialogManager = dialogManager; + if (provider == null) + throw new ArgumentNullException(nameof(provider)); + + this.dialogManager = dialogManager + ?? throw new ArgumentNullException(nameof(dialogManager)); + routes = provider.GetRequiredService(); } - public void Show(DialogParameters? parameters = null) where T : Window + public void Show(DialogParameters? parameters = null) where TWindow : Window { - var window = dialogManager.GetDialogWindow(); + Show(typeof(TWindow), parameters); + } - if (window == null) - return; + public void Show(Type targetType, DialogParameters? parameters = null) + { + ValidateWindowType(targetType); + ShowCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } - if (window.DataContext is IDialogAware vm) - { - vm.OnNavigated(parameters); - vm.RequestClose = (p) => window.Close(); - } + public void Show(string key, DialogParameters? parameters = null) + { + var targetType = routes.GetRequiredDialogType(key); + ValidateWindowType(targetType); + ShowCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } - if (window is IDialogAware w) - { - w.OnNavigated(parameters); - w.RequestClose = (p) => window.Close(); - } + public DialogParameters? ShowDialog(DialogParameters? parameters = null) + where TWindow : Window + { + return ShowDialog(typeof(TWindow), parameters); + } + + public DialogParameters? ShowDialog(Type targetType, DialogParameters? parameters = null) + { + ValidateWindowType(targetType); + return ShowDialogCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } + + public DialogParameters? ShowDialog(string key, DialogParameters? parameters = null) + { + var targetType = routes.GetRequiredDialogType(key); + ValidateWindowType(targetType); + return ShowDialogCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } + + public bool Close() where TWindow : Window + { + return Close(typeof(TWindow)); + } + + public bool Close(Type targetType) + { + ValidateWindowType(targetType); + return CloseCore(dialogManager.GetExistingWindow(targetType)); + } + + public bool Close(string key) + { + var targetType = routes.GetRequiredDialogType(key); + ValidateWindowType(targetType); + return CloseCore(dialogManager.GetExistingWindow(targetType)); + } + + private void ShowCore(Window window, DialogParameters? parameters) + { + window.Dispatcher.VerifyAccess(); + ConfigureNonModalAwareness(window, parameters); + + if (!window.IsVisible) + window.Show(); - window.Show(); window.Activate(); + } + + private DialogParameters? ShowDialogCore(Window window, DialogParameters? parameters) + { + window.Dispatcher.VerifyAccess(); + RemoveNonModalSubscription(window); + + DialogParameters? result = null; + var awareTargets = GetAwareTargets(window); + Action requestClose = closeResult => + { + window.Dispatcher.VerifyAccess(); + result = closeResult; + window.Close(); + }; + try + { + SetRequestClose(awareTargets, requestClose); + NotifyAwareTargets(awareTargets, parameters); + window.ShowDialog(); + return result; + } + finally + { + ClearRequestClose(awareTargets, requestClose); + } } - public DialogParameters? ShowDialog(DialogParameters? parameters = null) where T : Window + private bool CloseCore(Window? window) { - var window = dialogManager.GetDialogWindow(); if (window == null) - return null; + return false; - DialogParameters? result = null; + window.Dispatcher.VerifyAccess(); + var closed = false; + EventHandler closedHandler = (_, _) => closed = true; + window.Closed += closedHandler; - window.Closing += (s, e) => + try + { + window.Close(); + return closed; + } + finally { - e.Cancel = true; - if (s is IDialogAware dialogAware) - dialogAware.RequestClose?.Invoke(result); + window.Closed -= closedHandler; + } + } - if (s is Window w && w.DataContext is IDialogAware dialogVmAware) - dialogVmAware.RequestClose?.Invoke(result); + private void ConfigureNonModalAwareness(Window window, DialogParameters? parameters) + { + RemoveNonModalSubscription(window); + + var awareTargets = GetAwareTargets(window); + Action requestClose = _ => + { + window.Dispatcher.VerifyAccess(); + window.Close(); }; + NonModalSubscription? subscription = null; + EventHandler closedHandler = (_, _) => + { + if (subscription != null) + RemoveNonModalSubscription(window, subscription); + }; + subscription = new NonModalSubscription(awareTargets, requestClose, closedHandler); - if (window.DataContext is IDialogAware vm) + SetRequestClose(awareTargets, requestClose); + window.Closed += closedHandler; + lock (subscriptionSyncRoot) { - vm.OnNavigated(parameters); - vm.RequestClose = (p) => - { - result = p; - window.Close(); - }; + nonModalSubscriptions[window] = subscription; } - if (window is IDialogAware w) + NotifyAwareTargets(awareTargets, parameters); + } + + private void RemoveNonModalSubscription(Window window) + { + NonModalSubscription? subscription; + lock (subscriptionSyncRoot) { - w.OnNavigated(parameters); - w.RequestClose = (p) => + if (!nonModalSubscriptions.TryGetValue(window, out subscription)) + return; + + nonModalSubscriptions.Remove(window); + } + + window.Closed -= subscription.ClosedHandler; + ClearRequestClose(subscription.AwareTargets, subscription.RequestClose); + } + + private void RemoveNonModalSubscription( + Window window, + NonModalSubscription expectedSubscription) + { + lock (subscriptionSyncRoot) + { + if (!nonModalSubscriptions.TryGetValue(window, out var currentSubscription) + || !ReferenceEquals(currentSubscription, expectedSubscription)) { - result = p; - window.Close(); - }; + return; + } + + nonModalSubscriptions.Remove(window); } - try + window.Closed -= expectedSubscription.ClosedHandler; + ClearRequestClose(expectedSubscription.AwareTargets, expectedSubscription.RequestClose); + } + + private static IDialogAware[] GetAwareTargets(Window window) + { + var windowAware = window as IDialogAware; + var dataContextAware = window.DataContext as IDialogAware; + + if (windowAware == null) + return dataContextAware == null ? Array.Empty() : new[] { dataContextAware }; + + if (dataContextAware == null || ReferenceEquals(windowAware, dataContextAware)) + return new[] { windowAware }; + + return new[] { windowAware, dataContextAware }; + } + + private static void SetRequestClose( + IEnumerable awareTargets, + Action requestClose) + { + foreach (var aware in awareTargets) + aware.RequestClose = requestClose; + } + + private static void NotifyAwareTargets( + IEnumerable awareTargets, + DialogParameters? parameters) + { + foreach (var aware in awareTargets) + aware.OnNavigated(parameters); + } + + private static void ClearRequestClose( + IEnumerable awareTargets, + Action requestClose) + { + foreach (var aware in awareTargets) { - window.ShowDialog(); - window.Activate(); + if (ReferenceEquals(aware.RequestClose, requestClose)) + aware.RequestClose = null; } - finally + } + + private static void ValidateWindowType(Type targetType) + { + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + + if (!typeof(Window).IsAssignableFrom(targetType)) { - if (window.DataContext is IDialogAware cleanupVm) - cleanupVm.RequestClose = null; - else if (window is IDialogAware cleanupW) - cleanupW.RequestClose = null; + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Window.", + nameof(targetType)); + } + } + private sealed class NonModalSubscription + { + public NonModalSubscription( + IDialogAware[] awareTargets, + Action requestClose, + EventHandler closedHandler) + { + AwareTargets = awareTargets; + RequestClose = requestClose; + ClosedHandler = closedHandler; } - return result; + + public IDialogAware[] AwareTargets { get; } + + public Action RequestClose { get; } + + public EventHandler ClosedHandler { get; } } } } diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs new file mode 100644 index 0000000..cce7cb1 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -0,0 +1,450 @@ +using System.Threading; +using System.Windows; +using System.Windows.Threading; +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface.Services; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class DialogServiceTests +{ + [Fact] + public void Show_GenericTypeAndKey_ResolveOrdinaryDiAndPresentWindows() + { + StaTest.Run(() => + { + var firstCount = 0; + var secondCount = 0; + FirstWindow? currentFirst = null; + SecondWindow? currentSecond = null; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(_ => + { + firstCount++; + currentFirst = new FirstWindow(); + return currentFirst; + }); + services.AddTransient(_ => + { + secondCount++; + currentSecond = new SecondWindow(); + return currentSecond; + }); + services.AddWindow("second"); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.NotNull(currentFirst); + Assert.True(currentFirst!.IsVisible); + currentFirst.Close(); + + service.Show(typeof(FirstWindow)); + Assert.NotNull(currentFirst); + Assert.True(currentFirst!.IsVisible); + currentFirst.Close(); + + service.Show("second"); + + Assert.NotNull(currentSecond); + Assert.True(currentSecond!.IsVisible); + Assert.Equal(2, firstCount); + Assert.Equal(1, secondCount); + currentSecond.Close(); + }); + } + + [Fact] + public void Show_KeyIsCaseSensitiveAndUnknownKeyFailsBeforeDiResolution() + { + StaTest.Run(() => + { + var resolutionCount = 0; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("main"); + services.AddTransient(_ => + { + resolutionCount++; + return new FirstWindow(); + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Show("Main")); + Assert.Throws(() => service.Show("missing")); + Assert.Equal(0, resolutionCount); + }); + } + + [Fact] + public void Show_ReusesVisibleWindowThenCreatesNewTransientAfterClosed() + { + StaTest.Run(() => + { + var instances = new List(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(_ => + { + var window = new ReuseWindow(); + instances.Add(window); + return window; + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + service.Show(typeof(ReuseWindow)); + + Assert.Single(instances); + Assert.True(instances[0].IsVisible); + instances[0].Close(); + + service.Show(); + + Assert.Equal(2, instances.Count); + Assert.NotSame(instances[0], instances[1]); + instances[1].Close(); + }); + } + + [Fact] + public void Show_NotifiesWindowThenDistinctDataContextAndDeduplicatesSameReference() + { + StaTest.Run(() => + { + var calls = new List(); + var viewModel = new AwareDialogViewModel(calls); + var window = new AwareWindow(calls) { DataContext = viewModel }; + var parameters = new DialogParameters("answer", 42); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + + service.Show(parameters); + + Assert.Equal(new[] { "window", "view-model" }, calls); + Assert.Same(parameters, window.Parameters); + Assert.Same(parameters, viewModel.Parameters); + Assert.NotNull(window.RequestClose); + Assert.NotNull(viewModel.RequestClose); + viewModel.RequestClose!(null); + Assert.False(window.IsVisible); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + + var selfContext = new AwareWindow { DataContext = null }; + selfContext.DataContext = selfContext; + using var selfProvider = BuildProvider(services => services.AddSingleton(selfContext)); + selfProvider.GetRequiredService().Show(); + + Assert.Equal(1, selfContext.CallCount); + Assert.Null(selfContext.Parameters); + selfContext.Close(); + }); + } + + [Fact] + public void Show_AwarenessExceptionPropagatesAndWindowCanBeCleanedUp() + { + StaTest.Run(() => + { + var viewModel = new ThrowingAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Show()); + + Assert.Equal("dialog awareness failed", exception.Message); + Assert.False(window.IsVisible); + window.Close(); + }); + } + + [Fact] + public void ShowDialog_AllOverloadsCaptureRequestCloseResultAndCleanupAwareness() + { + StaTest.Run(() => + { + var results = new[] + { + new DialogParameters("path", "generic"), + new DialogParameters("path", "type"), + new DialogParameters("path", "key"), + }; + var windows = new List(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("aware"); + services.AddTransient(_ => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + var result = results[windows.Count]; + var invokeViewModel = windows.Count == 1; + windows.Add(window); + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => + { + if (invokeViewModel) + viewModel.RequestClose!(result); + else + window.RequestClose!(result); + })); + return window; + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Same(results[0], service.ShowDialog()); + Assert.Same(results[1], service.ShowDialog(typeof(AwareWindow))); + Assert.Same(results[2], service.ShowDialog("aware")); + + Assert.All(windows, window => + { + Assert.Null(window.RequestClose); + Assert.Null(((AwareDialogViewModel)window.DataContext).RequestClose); + }); + }); + } + + [Fact] + public void ShowDialog_DirectDispatcherCloseReturnsNullAndDoesNotCancelClosing() + { + StaTest.Run(() => + { + AwareWindow? window = null; + using var provider = BuildProvider(services => services.AddTransient(_ => + { + window = new AwareWindow { DataContext = new AwareDialogViewModel() }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + return window; + })); + + var result = provider.GetRequiredService().ShowDialog(); + + Assert.Null(result); + Assert.NotNull(window); + Assert.Null(window!.RequestClose); + Assert.Null(((AwareDialogViewModel)window.DataContext).RequestClose); + }); + } + + [Fact] + public void ShowDialog_AwarenessExceptionPropagatesAndCleansBothCallbacks() + { + StaTest.Run(() => + { + var viewModel = new ThrowingAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().ShowDialog()); + + Assert.Equal("dialog awareness failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + window.Close(); + }); + } + + [Fact] + public void ShowDialog_WpfPresentationExceptionStillCleansBothCallbacks() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + + Assert.Throws(() => + service.ShowDialog()); + + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + window.Close(); + }); + } + + [Fact] + public void ShowDialog_FinallyDoesNotClearAnUnrelatedCallbackReplacement() + { + StaTest.Run(() => + { + var viewModel = new ReplacingAwareDialogViewModel(); + AwareWindow? window = null; + using var provider = BuildProvider(services => services.AddTransient(_ => + { + window = new AwareWindow { DataContext = viewModel }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + return window; + })); + + Assert.Null(provider.GetRequiredService().ShowDialog()); + + Assert.NotNull(window); + Assert.Null(window!.RequestClose); + Assert.Same(viewModel.Replacement, viewModel.RequestClose); + }); + } + + [Fact] + public void Close_GenericTypeAndKey_CloseExistingWindows() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddWindow("second"); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.True(service.Close()); + service.Show(typeof(FirstWindow)); + Assert.True(service.Close(typeof(FirstWindow))); + service.Show("second"); + Assert.True(service.Close("second")); + }); + } + + [Fact] + public void Close_UnopenedWindowReturnsFalseWithoutInvokingDiFactory() + { + StaTest.Run(() => + { + var resolutionCount = 0; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + services.AddTransient(_ => + { + resolutionCount++; + return new FirstWindow(); + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.False(service.Close()); + Assert.False(service.Close(typeof(FirstWindow))); + Assert.False(service.Close("first")); + Assert.Equal(0, resolutionCount); + }); + } + + [Fact] + public void Close_CancelledClosingReturnsFalseAndWindowRemainsManaged() + { + StaTest.Run(() => + { + var window = new CancelClosingWindow { CancelClosing = true }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + + Assert.False(service.Close()); + Assert.True(window.IsVisible); + + window.CancelClosing = false; + Assert.True(service.Close()); + }); + } + + [Fact] + public void Close_OpenNonFocusedWindowStillCloses() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var activeWindow = new SecondWindow(); + activeWindow.Show(); + activeWindow.Activate(); + StaTest.PumpDispatcher(); + + Assert.False(window.IsActive); + Assert.True(service.Close()); + activeWindow.Close(); + }); + } + + [Fact] + public void Close_RequiresOwningWindowDispatcherThread() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + Exception? failure = null; + var worker = new Thread(() => + { + try + { + service.Close(); + } + catch (Exception exception) + { + failure = exception; + } + }); + worker.Start(); + Assert.True(worker.Join(TimeSpan.FromSeconds(5))); + + Assert.IsType(failure); + Assert.True(service.Close()); + }); + } + + [Fact] + public void InvalidTargetsAndKeysFailFastAndMissingDiExceptionIsPreserved() + { + using var provider = BuildProvider(); + var service = provider.GetRequiredService(); + + Assert.Equal("targetType", Assert.Throws( + () => service.Show((Type)null!)).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.ShowDialog((Type)null!)).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.Close((Type)null!)).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.Show(typeof(string))).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.ShowDialog(typeof(string))).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.Close(typeof(string))).ParamName); + Assert.Throws(() => service.Show(" ")); + Assert.Throws(() => service.Close("missing")); + + var exception = Assert.Throws( + () => service.Show(typeof(FirstWindow))); + Assert.Contains(typeof(FirstWindow).ToString(), exception.Message); + } + + private static ServiceProvider BuildProvider( + Action? configure = null) + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } +} diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index ff4921a..38550bb 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -1,5 +1,6 @@ using SimpleNavigation.Common; using SimpleNavigation.Interface.Awares; +using System.ComponentModel; using System.Windows; using System.Windows.Controls; @@ -33,6 +34,101 @@ public sealed class DialogViewModel { } +public class AwareDialogViewModel : IDialogAware +{ + private readonly IList? calls; + private readonly string name; + + public AwareDialogViewModel() + : this(null, "view-model") + { + } + + public AwareDialogViewModel(IList? calls, string name = "view-model") + { + this.calls = calls; + this.name = name; + } + + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public Action? RequestClose { get; set; } + + public virtual void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + calls?.Add(name); + } +} + +public class AwareWindow : Window, IDialogAware +{ + private readonly IList? calls; + private readonly string name; + + public AwareWindow() + : this(null, "window") + { + } + + public AwareWindow(IList? calls, string name = "window") + { + this.calls = calls; + this.name = name; + } + + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public Action? RequestClose { get; set; } + + public virtual void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + calls?.Add(name); + } +} + +public sealed class ThrowingAwareDialogViewModel : AwareDialogViewModel +{ + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + throw new InvalidOperationException("dialog awareness failed"); + } +} + +public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel +{ + public Action Replacement { get; } = _ => { }; + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + RequestClose = Replacement; + } +} + +public sealed class CancelClosingWindow : Window +{ + public bool CancelClosing { get; set; } + + protected override void OnClosing(CancelEventArgs e) + { + e.Cancel = CancelClosing; + base.OnClosing(e); + } +} + +public sealed class ReuseWindow : Window +{ +} + public sealed class AwareViewModel : INavigationAware { public int CallCount { get; private set; } From 6141d6d197f114e22396ed8934ccfad8fb48c4a6 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 22:39:21 +0800 Subject: [PATCH 31/42] fix: harden dialog service lifecycle --- Services/DialogService.cs | 88 +++++-- .../DialogServiceTests.cs | 226 +++++++++++++++++- Tests/SimpleNavigation.Tests/TestTypes.cs | 57 +++++ 3 files changed, 349 insertions(+), 22 deletions(-) diff --git a/Services/DialogService.cs b/Services/DialogService.cs index d2864ae..625f886 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -85,35 +85,81 @@ public bool Close(string key) private void ShowCore(Window window, DialogParameters? parameters) { window.Dispatcher.VerifyAccess(); - ConfigureNonModalAwareness(window, parameters); + var priorSubscription = RemoveNonModalSubscription(window); + var subscription = CreateNonModalSubscription(window); - if (!window.IsVisible) - window.Show(); + try + { + AttachNonModalSubscription(window, subscription); + NotifyAwareTargets(subscription.AwareTargets, parameters); + + if (!IsCurrentWindow(window)) + return; + + if (!window.IsVisible) + window.Show(); - window.Activate(); + window.Activate(); + } + catch + { + RemoveNonModalSubscription(window, subscription); + if (priorSubscription != null && IsCurrentWindow(window)) + AttachNonModalSubscription(window, priorSubscription); + + throw; + } } private DialogParameters? ShowDialogCore(Window window, DialogParameters? parameters) { window.Dispatcher.VerifyAccess(); - RemoveNonModalSubscription(window); + if (window.IsVisible) + { + throw new InvalidOperationException( + "ShowDialog cannot be called for a visible window."); + } + + var priorSubscription = RemoveNonModalSubscription(window); DialogParameters? result = null; var awareTargets = GetAwareTargets(window); Action requestClose = closeResult => { window.Dispatcher.VerifyAccess(); - result = closeResult; - window.Close(); + EventHandler? closedHandler = null; + closedHandler = (_, _) => result = closeResult; + window.Closed += closedHandler; + + try + { + window.Close(); + } + finally + { + window.Closed -= closedHandler; + } }; try { SetRequestClose(awareTargets, requestClose); NotifyAwareTargets(awareTargets, parameters); + + if (!IsCurrentWindow(window)) + return result; + window.ShowDialog(); return result; } + catch + { + ClearRequestClose(awareTargets, requestClose); + if (priorSubscription != null && IsCurrentWindow(window)) + AttachNonModalSubscription(window, priorSubscription); + + throw; + } finally { ClearRequestClose(awareTargets, requestClose); @@ -141,10 +187,8 @@ private bool CloseCore(Window? window) } } - private void ConfigureNonModalAwareness(Window window, DialogParameters? parameters) + private NonModalSubscription CreateNonModalSubscription(Window window) { - RemoveNonModalSubscription(window); - var awareTargets = GetAwareTargets(window); Action requestClose = _ => { @@ -158,30 +202,35 @@ private void ConfigureNonModalAwareness(Window window, DialogParameters? paramet RemoveNonModalSubscription(window, subscription); }; subscription = new NonModalSubscription(awareTargets, requestClose, closedHandler); + return subscription; + } - SetRequestClose(awareTargets, requestClose); - window.Closed += closedHandler; + private void AttachNonModalSubscription( + Window window, + NonModalSubscription subscription) + { + SetRequestClose(subscription.AwareTargets, subscription.RequestClose); + window.Closed += subscription.ClosedHandler; lock (subscriptionSyncRoot) { nonModalSubscriptions[window] = subscription; } - - NotifyAwareTargets(awareTargets, parameters); } - private void RemoveNonModalSubscription(Window window) + private NonModalSubscription? RemoveNonModalSubscription(Window window) { NonModalSubscription? subscription; lock (subscriptionSyncRoot) { if (!nonModalSubscriptions.TryGetValue(window, out subscription)) - return; + return null; nonModalSubscriptions.Remove(window); } window.Closed -= subscription.ClosedHandler; ClearRequestClose(subscription.AwareTargets, subscription.RequestClose); + return subscription; } private void RemoveNonModalSubscription( @@ -203,6 +252,13 @@ private void RemoveNonModalSubscription( ClearRequestClose(expectedSubscription.AwareTargets, expectedSubscription.RequestClose); } + private bool IsCurrentWindow(Window window) + { + return ReferenceEquals( + dialogManager.GetExistingWindow(window.GetType()), + window); + } + private static IDialogAware[] GetAwareTargets(Window window) { var windowAware = window as IDialogAware; diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs index cce7cb1..b10f3a2 100644 --- a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -1,10 +1,13 @@ +using System.Reflection; using System.Threading; using System.Windows; using System.Windows.Threading; using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; using SimpleNavigation.Extensions; +using SimpleNavigation.Interface.Managers; using SimpleNavigation.Interface.Services; +using SimpleNavigation.Services; using SimpleNavigation.Tests.TestInfrastructure; namespace SimpleNavigation.Tests; @@ -149,7 +152,7 @@ public void Show_NotifiesWindowThenDistinctDataContextAndDeduplicatesSameReferen } [Fact] - public void Show_AwarenessExceptionPropagatesAndWindowCanBeCleanedUp() + public void Show_AwarenessExceptionRollsBackNewSubscriptionImmediately() { StaTest.Run(() => { @@ -162,6 +165,87 @@ public void Show_AwarenessExceptionPropagatesAndWindowCanBeCleanedUp() Assert.Equal("dialog awareness failed", exception.Message); Assert.False(window.IsVisible); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_AwarenessFailureRestoresPriorLiveSubscription() + { + StaTest.Run(() => + { + var viewModel = new ToggleThrowAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var windowCallback = window.RequestClose; + var viewModelCallback = viewModel.RequestClose; + viewModel.ThrowOnNavigated = true; + + Assert.Throws(() => service.Show()); + + Assert.Same(windowCallback, window.RequestClose); + Assert.Same(viewModelCallback, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + windowCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_PresentationFailureRollsBackNewSubscriptionImmediately() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ThrowingPresentationWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Show()); + + Assert.Equal("window presentation failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_SynchronousRequestCloseReturnsWithoutPresentingClosedWindow() + { + StaTest.Run(() => + { + var window = new SynchronousCloseAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + + provider.GetRequiredService().Show(); + + Assert.False(window.IsVisible); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(SynchronousCloseAwareWindow))); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_SynchronousCancelledRequestCloseContinuesPresentation() + { + StaTest.Run(() => + { + var window = new CancelFirstCloseAwareWindow + { + RequestCloseOnNavigated = true, + }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + provider.GetRequiredService().Show(); + + Assert.True(window.IsVisible); + Assert.Equal(1, window.ClosingCount); window.Close(); }); } @@ -258,7 +342,7 @@ public void ShowDialog_AwarenessExceptionPropagatesAndCleansBothCallbacks() } [Fact] - public void ShowDialog_WpfPresentationExceptionStillCleansBothCallbacks() + public void ShowDialog_VisibleNonModalWindowFailsBeforeReplacingCallbacks() { StaTest.Run(() => { @@ -267,13 +351,89 @@ public void ShowDialog_WpfPresentationExceptionStillCleansBothCallbacks() using var provider = BuildProvider(services => services.AddSingleton(window)); var service = provider.GetRequiredService(); service.Show(); + var windowCallback = window.RequestClose; + var viewModelCallback = viewModel.RequestClose; - Assert.Throws(() => + var exception = Assert.Throws(() => service.ShowDialog()); - Assert.Null(window.RequestClose); - Assert.Null(viewModel.RequestClose); - window.Close(); + Assert.Contains("visible", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Same(windowCallback, window.RequestClose); + Assert.Same(viewModelCallback, viewModel.RequestClose); + windowCallback!(null); + Assert.False(window.IsVisible); + }); + } + + [Fact] + public void ShowDialog_SynchronousRequestCloseReturnsCommittedResultWithoutPresentation() + { + StaTest.Run(() => + { + var result = new DialogParameters("result", 42); + var window = new SynchronousCloseAwareWindow { CloseResult = result }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Same(result, actual); + Assert.False(window.IsVisible); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(SynchronousCloseAwareWindow))); + }); + } + + [Fact] + public void ShowDialog_CancelledRequestCloseDoesNotCommitResultAndDirectCloseReturnsNull() + { + StaTest.Run(() => + { + var candidate = new DialogParameters("candidate", 1); + CancelFirstCloseAwareWindow? window = null; + using var provider = BuildProvider(services => + services.AddTransient(_ => + { + window = new CancelFirstCloseAwareWindow { CloseResult = candidate }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => window.RequestClose!(candidate))); + window.Dispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(window.Close)); + return window; + })); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Null(actual); + Assert.NotNull(window); + Assert.Equal(2, window!.ClosingCount); + }); + } + + [Fact] + public void ShowDialog_SynchronousCancelledRequestCloseContinuesModalPresentation() + { + StaTest.Run(() => + { + var candidate = new DialogParameters("candidate", 1); + var window = new CancelFirstCloseAwareWindow + { + RequestCloseOnNavigated = true, + CloseResult = candidate, + }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(window.Close)); + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Null(actual); + Assert.Equal(2, window.ClosingCount); }); } @@ -413,6 +573,47 @@ public void Close_RequiresOwningWindowDispatcherThread() }); } + [Fact] + public void ShowAndShowDialog_RequireOwningWindowDispatcherThread() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + provider.GetRequiredService() + .GetOrCreateWindow(typeof(FirstWindow)); + var service = provider.GetRequiredService(); + Exception? showFailure = null; + Exception? modalFailure = null; + var worker = new Thread(() => + { + try + { + service.Show(); + } + catch (Exception exception) + { + showFailure = exception; + } + + try + { + service.ShowDialog(); + } + catch (Exception exception) + { + modalFailure = exception; + } + }); + worker.Start(); + Assert.True(worker.Join(TimeSpan.FromSeconds(5))); + + Assert.IsType(showFailure); + Assert.IsType(modalFailure); + window.Close(); + }); + } + [Fact] public void InvalidTargetsAndKeysFailFastAndMissingDiExceptionIsPreserved() { @@ -447,4 +648,17 @@ private static ServiceProvider BuildProvider( configure?.Invoke(services); return services.BuildServiceProvider(); } + + private static int GetNonModalSubscriptionCount(IServiceProvider provider) + { + var service = Assert.IsType( + provider.GetRequiredService()); + var field = typeof(DialogService).GetField( + "nonModalSubscriptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var subscriptions = Assert.IsAssignableFrom( + field!.GetValue(service)); + return subscriptions.Count; + } } diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index 38550bb..c664d4b 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -103,6 +103,18 @@ public override void OnNavigated(DialogParameters? parameters) } } +public sealed class ToggleThrowAwareDialogViewModel : AwareDialogViewModel +{ + public bool ThrowOnNavigated { get; set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + if (ThrowOnNavigated) + throw new InvalidOperationException("dialog awareness failed"); + } +} + public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel { public Action Replacement { get; } = _ => { }; @@ -129,6 +141,51 @@ public sealed class ReuseWindow : Window { } +public sealed class ThrowingPresentationWindow : AwareWindow +{ + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + throw new InvalidOperationException("window presentation failed"); + } +} + +public sealed class SynchronousCloseAwareWindow : AwareWindow +{ + public DialogParameters? CloseResult { get; set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + RequestClose!(CloseResult); + } +} + +public sealed class CancelFirstCloseAwareWindow : AwareWindow +{ + public bool RequestCloseOnNavigated { get; set; } + + public DialogParameters? CloseResult { get; set; } + + public int ClosingCount { get; private set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + if (RequestCloseOnNavigated) + RequestClose!(CloseResult); + } + + protected override void OnClosing(CancelEventArgs e) + { + ClosingCount++; + if (ClosingCount == 1) + e.Cancel = true; + + base.OnClosing(e); + } +} + public sealed class AwareViewModel : INavigationAware { public int CallCount { get; private set; } From aaeef454b3fae14cc2a0c551e8b964a4c4c880a5 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 22:59:01 +0800 Subject: [PATCH 32/42] fix: guard dialog presentation reentrancy --- Services/DialogService.cs | 109 ++++++++++++++-- .../DialogServiceTests.cs | 122 ++++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 13 ++ 3 files changed, 235 insertions(+), 9 deletions(-) diff --git a/Services/DialogService.cs b/Services/DialogService.cs index 625f886..cf52ed5 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -3,6 +3,7 @@ using SimpleNavigation.Interface.Awares; using SimpleNavigation.Interface.Managers; using SimpleNavigation.Interface.Services; +using System.Runtime.CompilerServices; using System.Windows; namespace SimpleNavigation.Services @@ -16,6 +17,9 @@ public class DialogService : IDialogService private readonly NavigationRouteRegistry routes; private readonly object subscriptionSyncRoot = new(); private readonly Dictionary nonModalSubscriptions = new(); + private readonly object modalSyncRoot = new(); + private readonly HashSet activeModalWindows = + new(WindowReferenceComparer.Instance); public DialogService(IServiceProvider provider, IDialogManager dialogManager) { @@ -85,6 +89,12 @@ public bool Close(string key) private void ShowCore(Window window, DialogParameters? parameters) { window.Dispatcher.VerifyAccess(); + if (IsModalActive(window)) + { + throw new InvalidOperationException( + "Show cannot replace an active modal presentation for the same window."); + } + var priorSubscription = RemoveNonModalSubscription(window); var subscription = CreateNonModalSubscription(window); @@ -103,9 +113,14 @@ private void ShowCore(Window window, DialogParameters? parameters) } catch { - RemoveNonModalSubscription(window, subscription); - if (priorSubscription != null && IsCurrentWindow(window)) - AttachNonModalSubscription(window, priorSubscription); + var removedSubscription = + RemoveNonModalSubscription(window, subscription); + if (removedSubscription + && priorSubscription != null + && IsCurrentWindow(window)) + { + TryAttachNonModalSubscription(window, priorSubscription); + } throw; } @@ -114,12 +129,28 @@ private void ShowCore(Window window, DialogParameters? parameters) private DialogParameters? ShowDialogCore(Window window, DialogParameters? parameters) { window.Dispatcher.VerifyAccess(); - if (window.IsVisible) + ClaimModalOwnership(window); + + try { - throw new InvalidOperationException( - "ShowDialog cannot be called for a visible window."); + if (window.IsVisible) + { + throw new InvalidOperationException( + "ShowDialog cannot be called for a visible window."); + } + + return ShowDialogTransaction(window, parameters); } + finally + { + ReleaseModalOwnership(window); + } + } + private DialogParameters? ShowDialogTransaction( + Window window, + DialogParameters? parameters) + { var priorSubscription = RemoveNonModalSubscription(window); DialogParameters? result = null; @@ -156,7 +187,7 @@ private void ShowCore(Window window, DialogParameters? parameters) { ClearRequestClose(awareTargets, requestClose); if (priorSubscription != null && IsCurrentWindow(window)) - AttachNonModalSubscription(window, priorSubscription); + TryAttachNonModalSubscription(window, priorSubscription); throw; } @@ -217,6 +248,22 @@ private void AttachNonModalSubscription( } } + private bool TryAttachNonModalSubscription( + Window window, + NonModalSubscription subscription) + { + lock (subscriptionSyncRoot) + { + if (nonModalSubscriptions.ContainsKey(window)) + return false; + + SetRequestClose(subscription.AwareTargets, subscription.RequestClose); + window.Closed += subscription.ClosedHandler; + nonModalSubscriptions.Add(window, subscription); + return true; + } + } + private NonModalSubscription? RemoveNonModalSubscription(Window window) { NonModalSubscription? subscription; @@ -233,7 +280,7 @@ private void AttachNonModalSubscription( return subscription; } - private void RemoveNonModalSubscription( + private bool RemoveNonModalSubscription( Window window, NonModalSubscription expectedSubscription) { @@ -242,7 +289,7 @@ private void RemoveNonModalSubscription( if (!nonModalSubscriptions.TryGetValue(window, out var currentSubscription) || !ReferenceEquals(currentSubscription, expectedSubscription)) { - return; + return false; } nonModalSubscriptions.Remove(window); @@ -250,6 +297,35 @@ private void RemoveNonModalSubscription( window.Closed -= expectedSubscription.ClosedHandler; ClearRequestClose(expectedSubscription.AwareTargets, expectedSubscription.RequestClose); + return true; + } + + private bool IsModalActive(Window window) + { + lock (modalSyncRoot) + { + return activeModalWindows.Contains(window); + } + } + + private void ClaimModalOwnership(Window window) + { + lock (modalSyncRoot) + { + if (!activeModalWindows.Add(window)) + { + throw new InvalidOperationException( + "A modal presentation is already active for this window."); + } + } + } + + private void ReleaseModalOwnership(Window window) + { + lock (modalSyncRoot) + { + activeModalWindows.Remove(window); + } } private bool IsCurrentWindow(Window window) @@ -331,5 +407,20 @@ public NonModalSubscription( public EventHandler ClosedHandler { get; } } + + private sealed class WindowReferenceComparer : IEqualityComparer + { + public static WindowReferenceComparer Instance { get; } = new(); + + public bool Equals(Window? x, Window? y) + { + return ReferenceEquals(x, y); + } + + public int GetHashCode(Window obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } } } diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs index b10f3a2..c7610a3 100644 --- a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -195,6 +195,40 @@ public void Show_AwarenessFailureRestoresPriorLiveSubscription() }); } + [Fact] + public void Show_ReentrantSuccessfulReplacementSurvivesOuterRollback() + { + StaTest.Run(() => + { + var viewModel = new ThrowingAwareDialogViewModel(); + var window = new OneShotReentrantAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + window.Hide(); + + Action? replacementCallback = null; + window.DataContext = viewModel; + window.NavigatedAction = () => + { + window.DataContext = null; + service.Show(); + replacementCallback = window.RequestClose; + window.DataContext = viewModel; + }; + + Assert.Throws(() => + service.Show()); + + Assert.NotNull(replacementCallback); + Assert.Same(replacementCallback, window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + replacementCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + [Fact] public void Show_PresentationFailureRollsBackNewSubscriptionImmediately() { @@ -365,6 +399,94 @@ public void ShowDialog_VisibleNonModalWindowFailsBeforeReplacingCallbacks() }); } + [Fact] + public void ShowDialog_ReentrantShowFailsWithoutReplacingModalCallback() + { + StaTest.Run(() => + { + var result = new DialogParameters("result", 42); + var window = new OneShotReentrantAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + Exception? reentrantFailure = null; + var callbackWasPreserved = false; + window.NavigatedAction = () => + { + var modalCallback = window.RequestClose; + try + { + service.Show(); + } + catch (Exception exception) + { + reentrantFailure = exception; + } + + callbackWasPreserved = ReferenceEquals(modalCallback, window.RequestClose); + if (reentrantFailure != null) + { + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => window.RequestClose!(result))); + } + }; + + DialogParameters? actual = null; + try + { + actual = service.ShowDialog(); + } + finally + { + if (window.IsVisible) + window.Close(); + } + + var exception = Assert.IsType(reentrantFailure); + Assert.Contains("modal", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(callbackWasPreserved); + Assert.Same(result, actual); + Assert.Null(window.RequestClose); + }); + } + + [Fact] + public void ShowDialog_NestedSameWindowFailsBeforeReplacingOuterCallback() + { + StaTest.Run(() => + { + var window = new OneShotReentrantAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + Exception? nestedFailure = null; + var callbackWasPreserved = false; + window.NavigatedAction = () => + { + var outerCallback = window.RequestClose; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + try + { + service.ShowDialog(); + } + catch (Exception exception) + { + nestedFailure = exception; + } + + callbackWasPreserved = ReferenceEquals(outerCallback, window.RequestClose); + }; + + Assert.Null(service.ShowDialog()); + + var exception = Assert.IsType(nestedFailure); + Assert.Contains("modal", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(callbackWasPreserved); + Assert.Null(window.RequestClose); + }); + } + [Fact] public void ShowDialog_SynchronousRequestCloseReturnsCommittedResultWithoutPresentation() { diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index c664d4b..56d9d1a 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -115,6 +115,19 @@ public override void OnNavigated(DialogParameters? parameters) } } +public sealed class OneShotReentrantAwareWindow : AwareWindow +{ + public Action? NavigatedAction { get; set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + var action = NavigatedAction; + NavigatedAction = null; + action?.Invoke(); + } +} + public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel { public Action Replacement { get; } = _ => { }; From 74a68974325f6f873b92df5dddfdaf3f2d2a6be4 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 23:13:46 +0800 Subject: [PATCH 33/42] docs: document keyed dialog service --- README.md | 58 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index db6ec93..3f88fe2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SimpleNavigation 是一个基于 `Microsoft.Extensions.DependencyInjection` çš„ - `PageService`:在命åçš„ `Frame` 区域中导航 `Page`,支æŒè¿”回上一页。 - `ContentService`:在命åçš„éž `Frame` `ContentControl` 区域中显示 `UserControl`ã€è‡ªå®šä¹‰ `ContentControl` æˆ–å…¶ä»–éž `Page`ã€éž `Window` çš„ `FrameworkElement`。 -- `DialogService`:通过 DI 创建并显示 `Window`;当å‰å¯é è·¯å¾„æ˜¯éžæ¨¡æ€æ˜¾ç¤ºã€å‚数传递与关闭请求。 +- `DialogService`:通过 DI 或独立的 Dialog å­—ç¬¦ä¸²è·¯ç”±æ˜¾ç¤ºã€æ¨¡æ€æ˜¾ç¤ºå’Œå…³é—­ `Window`,并支æŒå‚数与模æ€ç»“果传递。 - `RegionManager`ï¼šç»Ÿä¸€ç®¡ç† XAML å£°æ˜Žæˆ–ä»£ç æ³¨å†Œçš„命å区域,并以弱引用ä¿å­˜åŒºåŸŸå®¿ä¸»ã€‚ 导航目标全部由应用的 DI 容器创建。类库ä¸è®¾ç½® `DataContext`,因此 View 与 ViewModel 的构造注入和绑定方å¼ä»ç”±åº”用决定。 @@ -36,9 +36,9 @@ SimpleNavigation/ IContentService.cs # FrameworkElement 内容导航 INavigationAware.cs # 导航完æˆé€šçŸ¥ IPageAware.cs # 兼容旧代ç ï¼Œç»§æ‰¿ INavigationAware - IDialogService.cs # Window 显示æœåŠ¡ + IDialogService.cs # Window æ˜¾ç¤ºã€æ¨¡æ€æ˜¾ç¤ºä¸Žå…³é—­æœåŠ¡ IDialogAware.cs # Window 导航与关闭通知 - IDialogManager.cs # Window å®žä¾‹ç®¡ç† + IDialogManager.cs # Window 获å–ã€å¤ç”¨ä¸ŽçŽ°æœ‰å®žä¾‹æŸ¥è¯¢ Services/ Region.cs # RegionName 附加属性 PageService.cs # Frame/Page 导航实现 @@ -72,13 +72,18 @@ services.AddPage("reports"); services.AddContent("help"); services.AddContent(); services.AddContent("status"); +services.AddWindow("login"); +services.AddWindow(); +services.AddWindow("reports"); var provider = services.BuildServiceProvider(); ``` -`AddPage` 与 `AddContent` 使用 `TryAddTransient` 注册 View å’Œå¯é€‰çš„ ViewModel,ä¸ä¼šè¦†ç›–åœ¨å®ƒä»¬ä¹‹å‰æ·»åŠ çš„æ³¨å†Œã€‚éœ€è¦è‡ªå®šä¹‰ç”Ÿå‘½å‘¨æœŸæˆ–工厂时,应先使用标准 DI 方法注册对应æœåŠ¡ã€‚è¿™äº›æ‰©å±•æ–¹æ³•ä¸ä¼šåˆ›å»ºæˆ–设置 View çš„ `DataContext`。 +`AddWindow` 使用 `TryAddTransient` 注册 Window å’Œå¯é€‰çš„ ViewModel,ä¸ä¼šè¦†ç›–åœ¨å®ƒä¹‹å‰æ·»åŠ çš„æ³¨å†Œã€‚éœ€è¦è‡ªå®šä¹‰ç”Ÿå‘½å‘¨æœŸæˆ–工厂时,应先使用标准 DI 方法注册对应æœåŠ¡ã€‚å®ƒåªè´Ÿè´£æ³¨å†Œï¼Œç»ä¸ä¼šè®¾ç½® Window çš„ `DataContext`。 -åŒæ³›åž‹æ—  key çš„é‡è½½åªæ³¨å†Œ View å’Œ ViewModelï¼›å•æ³›åž‹åŠ  key çš„é‡è½½æ³¨å†Œ View 与路由别åï¼›åŒæ³›åž‹åŠ  key çš„é‡è½½åŒæ—¶æ³¨å†Œ Viewã€ViewModel 与路由别å。Page 与 Content çš„ key 空间相互独立ã€åŒºåˆ†å¤§å°å†™ï¼ŒåŒä¸€ç©ºé—´å†…ä¸èƒ½é‡å¤æ³¨å†Œ key。 +`AddPage` 与 `AddContent` åŒæ ·ä¿ç•™æ›´æ—©çš„æ³¨å†Œï¼Œä¹Ÿä¸ä¼šåˆ›å»ºæˆ–设置 View çš„ `DataContext`。 + +åŒæ³›åž‹æ—  key çš„é‡è½½åªæ³¨å†Œ View å’Œ ViewModelï¼›å•æ³›åž‹åŠ  key çš„é‡è½½æ³¨å†Œ View 与路由别åï¼›åŒæ³›åž‹åŠ  key çš„é‡è½½åŒæ—¶æ³¨å†Œ Viewã€ViewModel 与路由别å。Pageã€Content 与 Dialog çš„ key 空间相互独立,å‡é‡‡ç”¨ ordinalã€åŒºåˆ†å¤§å°å†™çš„æ¯”较;åŒä¸€ä¸ª key å¯ä»¥åˆ†åˆ«ç”¨äºŽä¸‰ç§è·¯ç”±ï¼Œä½†åŒä¸€ç©ºé—´å†…ä¸èƒ½é‡å¤æ³¨å†Œã€‚ ## 声明导航区域 @@ -193,25 +198,40 @@ public sealed class StatusViewModel : INavigationAware ## DialogService -Window åŠå…¶ä¾èµ–需è¦å…ˆæ³¨å†Œåˆ° DI: +Window åŠå…¶ä¾èµ–需è¦å…ˆæ³¨å†Œåˆ° DI;需è¦å­—符串 key æ—¶å†æ³¨å†Œ Dialog 路由: ```csharp -services.AddTransient(); -services.AddTransient(); +services.AddWindow("login"); +services.AddWindow(); +services.AddWindow("reports"); ``` -使用 `IDialogService` æ˜¾ç¤ºéžæ¨¡æ€çª—å£ï¼š +`IDialogService` çš„éžæ¨¡æ€æ˜¾ç¤ºã€æ¨¡æ€æ˜¾ç¤ºä¸Žå…³é—­æ“作都æä¾›æ³›åž‹ã€`Type` 和字符串 key 三ç§å½¢å¼ï¼š ```csharp -var input = new DialogParameters("id", 42); +dialogService.Show(); +dialogService.Show(typeof(LoginWindow)); +dialogService.Show("login"); + +DialogParameters? genericResult = dialogService.ShowDialog(); +DialogParameters? typeResult = dialogService.ShowDialog(typeof(LoginWindow)); +DialogParameters? keyResult = dialogService.ShowDialog("login"); -dialogService.Show(input); +bool closedByGeneric = dialogService.Close(); +bool closedByType = dialogService.Close(typeof(LoginWindow)); +bool closedByKey = dialogService.Close("login"); ``` -对于 `Show`,Window 或它的 `DataContext` å¯ä»¥å®žçް `IDialogAware` æ¥æŽ¥æ”¶å‚æ•°å’Œè¯·æ±‚关闭: +`Show` 与 `ShowDialog` 的三ç§é‡è½½éƒ½å¯ä»¥é¢å¤–接收 `DialogParameters`。 + +泛型与 `Type` é‡è½½åªéœ€è¦æ™®é€š DI 注册,ä¸è¯»å–路由表。字符串é‡è½½å…ˆåœ¨ç‹¬ç«‹çš„ Dialog 路由空间中按 ordinalã€åŒºåˆ†å¤§å°å†™çš„ key 查找 Window 类型,éšåŽä»é€šè¿‡æ™®é€š DI è§£æžå®žä¾‹ï¼›æœªçŸ¥ key 会抛出 `KeyNotFoundException`。Pageã€Content 与 Dialog å¯ä½¿ç”¨ç›¸åŒ key,互ä¸å†²çªã€‚ + +`AddWindow` 默认把 Window 注册为 transient,但 `DialogManager` 会在窗å£ä»ç„¶å­˜æ´»ä¸”未关闭时å¤ç”¨å½“å‰å®žä¾‹ã€‚因此,对åŒä¸€ Window 类型é‡å¤ `Show` 会显示或激活当å‰å®žä¾‹ï¼›å³ä½¿å®ƒå½“剿²¡æœ‰ç„¦ç‚¹ï¼Œ`Close` 也能关闭它。窗å£è§¦å‘ `Closed` åŽè®°å½•会移除,下一次 `Show` æ‰ä»Ž DI 创建新的 transient 实例。`Close` åªæŸ¥è¯¢çŽ°æœ‰å®žä¾‹ï¼Œç»ä¸ä¼šä¸ºäº†å…³é—­è€Œåˆ›å»ºçª—å£ï¼›æ²¡æœ‰å—管ç†çš„现有实例时返回 `false`,`Closing` è¢«å–æ¶ˆæ—¶ä¹Ÿè¿”回 `false`。 + +Window 以åŠå®ƒçš„ã€ä¸”与 Window ä¸åŒçš„ `DataContext` 都å¯ä»¥å®žçް `IDialogAware`,两者会按 Windowã€DataContext çš„é¡ºåºæŽ¥æ”¶å‚æ•°å’Œå…³é—­å›žè°ƒã€‚类库ä¸ä¼šè®¾ç½®æˆ–æ›¿æ¢ `DataContext`: ```csharp -public sealed class TestViewModel : IDialogAware +public sealed class LoginViewModel : IDialogAware { public Action? RequestClose { get; set; } @@ -220,16 +240,16 @@ public sealed class TestViewModel : IDialogAware var id = parameters?.Get("id"); } - public void Close() + public void Accept() { - RequestClose?.Invoke(null); + RequestClose?.Invoke(new DialogParameters("accepted", true)); } } ``` -`ShowDialog` 当å‰çš„关闭/结果æµç¨‹å­˜åœ¨å·²çŸ¥é™åˆ¶ï¼šå…¶ `Closing` 处ç†ä¼šå–消关闭,并å¯èƒ½åœ¨å…³é—­è¯·æ±‚䏭冿¬¡è°ƒç”¨ `Close`。在 `DialogService` 修正å‰ï¼Œä¸åº”ä¾èµ–è¯¥æ–¹æ³•å®Œæˆæ¨¡æ€å…³é—­æˆ–返回结果。 +åœ¨æ¨¡æ€æ˜¾ç¤ºä¸­ï¼Œ`RequestClose(result)` åªæœ‰åœ¨ Window 实际完æˆå…³é—­åŽæ‰æäº¤å¹¶ç”± `ShowDialog` è¿”å›žï¼›å¦‚æžœå…³é—­è¢«å–æ¶ˆï¼Œè¯¥å€™é€‰ç»“æžœä¸ä¼šæäº¤ã€‚用户通过系统关闭按钮等方å¼ç›´æŽ¥å…³é—­æ¨¡æ€çª—壿—¶è¿”回 `null`ã€‚éžæ¨¡æ€ä¸Žæ¨¡æ€æ“作会以事务方å¼å®‰è£…ã€æ¸…ç†æˆ–在失败时æ¢å¤ç›¸å…³å›žè°ƒï¼Œä¸”ä¸ä¼šæ¸…除应用自行替æ¢çš„回调。对åŒä¸€ Window çš„æ´»åŠ¨æ¨¡æ€æ˜¾ç¤ºæ‰§è¡Œé‡å…¥çš„ `Show`/`ShowDialog` 会被拒ç»ï¼Œä»¥å…è¦†ç›–å½“å‰æ¨¡æ€äº‹åŠ¡ã€‚ -`DialogManager` 使用弱引用缓存åŒç±»åž‹ Window,并在 Window 关闭åŽç§»é™¤è®°å½•。 +Window 的显示ã€å…³é—­ä»¥åŠ `RequestClose` 必须在其所属 Dispatcher 线程调用。 ## DialogParameters @@ -254,7 +274,7 @@ var value = byKey.Get("key"); `RegisterNavigationService()` 默认把 `IRegionManager`ã€`IPageService`ã€`IContentService`ã€`IDialogService` å’Œ `IDialogManager` 注册为 singleton。`IRegionManager` ä¿å­˜å‘½ååŒºåŸŸå®¿ä¸»çš„å¼±å¼•ç”¨ï¼Œå¹¶æŒæœ‰é™æ€ `Region` 声明订阅;它ä¸è§£æž View 对象图,并会在所属 DI provider é‡Šæ”¾æ—¶å–æ¶ˆè®¢é˜…。 -Page/Content 导航æœåŠ¡ä¸Ž `DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的目标解æžåˆ‡æ¢åˆ°è¯¥ scope。 +Page/Content 导航æœåŠ¡ä»¥åŠ singleton çš„ `DialogService`/`DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的目标解æžåˆ‡æ¢åˆ°è¯¥ scope。`AddWindow` 默认注册 transient Window å’Œ ViewModel,但从根 provider è§£æžçš„ disposable transient 或 scoped 对象图ä»å…·æœ‰ä¸‹è¿°æ—¢æœ‰ç”Ÿå‘½å‘¨æœŸé™åˆ¶ã€‚ 因此,在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped Viewã€ViewModel 或 Window 对象图是无效的;从根容器解æžçš„ disposable transient 对象会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚类库ä¸ä¼šä¸ºå¯¼èˆªæˆ–窗å£åˆ›å»ºã€æŒæœ‰æˆ–释放 scope,因为已显示 UI 的生命周期属于应用。 @@ -278,6 +298,8 @@ IPageService.Goback(...) -> IPageService.GoBack(...) `RegionService` 已删除。`Goback` 仅作为标记了 `Obsolete` çš„è½¬å‘æ–¹æ³•ä¿ç•™ï¼ŒçŽ°æœ‰ä»£ç åº”è¿ç§»åˆ° `GoBack`。由于附加属性所有者å‘生å˜åŒ–,引用旧属性的已编译 XAML/BAML å¿…é¡»é‡æ–°æž„建。 +Dialog API ä¹Ÿæœ‰ç ´åæ€§å˜åŒ–:`IDialogService` 新增了 `Type`ã€å­—符串 key 与 `Close` æˆå‘˜ï¼›è‡ªå®šä¹‰ `IDialogService` 实现必须补é½è¿™äº›æˆå‘˜ã€‚自定义 `IDialogManager` 实现必须新增 `GetOrCreateWindow(Type)` 与 `GetExistingWindow(Type)`。直接构造 `DialogService` 的代ç çŽ°åœ¨å¿…é¡»åŒæ—¶ä¼ å…¥ `IServiceProvider` å’Œ `IDialogManager`。因此包å«è¿™äº›å˜æ›´çš„ NuGet åŒ…åº”ä½œä¸ºç ´åæ€§çš„ 2.x 版本å‡çº§å¤„ç†ã€‚ + ## License This project is licensed under the [MIT License](LICENSE). From 2f5aea413e55e985ac0f654d9aa82d0ac95f7dd1 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 23:19:35 +0800 Subject: [PATCH 34/42] docs: correct dialog provider lifetime guidance --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f88fe2..9e8fa79 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ var value = byKey.Get("key"); `RegisterNavigationService()` 默认把 `IRegionManager`ã€`IPageService`ã€`IContentService`ã€`IDialogService` å’Œ `IDialogManager` 注册为 singleton。`IRegionManager` ä¿å­˜å‘½ååŒºåŸŸå®¿ä¸»çš„å¼±å¼•ç”¨ï¼Œå¹¶æŒæœ‰é™æ€ `Region` 声明订阅;它ä¸è§£æž View 对象图,并会在所属 DI provider é‡Šæ”¾æ—¶å–æ¶ˆè®¢é˜…。 -Page/Content 导航æœåŠ¡ä»¥åŠ singleton çš„ `DialogService`/`DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的目标解æžåˆ‡æ¢åˆ°è¯¥ scope。`AddWindow` 默认注册 transient Window å’Œ ViewModel,但从根 provider è§£æžçš„ disposable transient 或 scoped 对象图ä»å…·æœ‰ä¸‹è¿°æ—¢æœ‰ç”Ÿå‘½å‘¨æœŸé™åˆ¶ã€‚ +singleton çš„ `PageService`ã€`ContentService` å’Œ `DialogManager` 会æ•获创建它们的 `IServiceProvider`ï¼ˆé€šå¸¸æ˜¯æ ¹å®¹å™¨ï¼‰ï¼›ä»…åˆ›å»ºæˆ–æŒæœ‰ä¸€ä¸ªå­ scope ä¸ä¼šæŠŠè¿™äº› singleton 的目标解æžåˆ‡æ¢åˆ°è¯¥ scope。`DialogService` 构造时从 provider å–å¾—å¹¶ä¿å­˜è·¯ç”±æ³¨å†Œè¡¨ï¼Œä¹‹åŽåªæŒæœ‰è¯¥æ³¨å†Œè¡¨ä¸Ž `IDialogManager`,ä¸ä¼šä¿ç•™ provider æˆ–ç›´æŽ¥è§£æž Windowï¼›Window 对象图由 `DialogManager` 从它æ•获的 provider è§£æžã€‚`AddWindow` 默认注册 transient Window å’Œ ViewModel,但从根 provider è§£æžçš„ disposable transient 或 scoped 对象图ä»å…·æœ‰ä¸‹è¿°æ—¢æœ‰ç”Ÿå‘½å‘¨æœŸé™åˆ¶ã€‚ 因此,在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped Viewã€ViewModel 或 Window 对象图是无效的;从根容器解æžçš„ disposable transient 对象会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚类库ä¸ä¼šä¸ºå¯¼èˆªæˆ–窗å£åˆ›å»ºã€æŒæœ‰æˆ–释放 scope,因为已显示 UI 的生命周期属于应用。 From ec75e8293dd63c827e603ed63d0a0d7ce834e021 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 23:27:08 +0800 Subject: [PATCH 35/42] docs: clarify navigation view lifetimes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9e8fa79..9e3d835 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ services.AddWindow("reports"); var provider = services.BuildServiceProvider(); ``` -`AddWindow` 使用 `TryAddTransient` 注册 Window å’Œå¯é€‰çš„ ViewModel,ä¸ä¼šè¦†ç›–åœ¨å®ƒä¹‹å‰æ·»åŠ çš„æ³¨å†Œã€‚éœ€è¦è‡ªå®šä¹‰ç”Ÿå‘½å‘¨æœŸæˆ–工厂时,应先使用标准 DI 方法注册对应æœåŠ¡ã€‚å®ƒåªè´Ÿè´£æ³¨å†Œï¼Œç»ä¸ä¼šè®¾ç½® Window çš„ `DataContext`。 +`AddWindow` 使用 `TryAddTransient` 注册 Window å’Œå¯é€‰çš„ ViewModel,ä¸ä¼šè¦†ç›–åœ¨å®ƒä¹‹å‰æ·»åŠ çš„æ³¨å†Œã€‚éœ€è¦è‡ªå®šä¹‰ç”Ÿå‘½å‘¨æœŸæˆ–工厂时,应先使用标准 DI 方法注册对应æœåŠ¡ï¼›ä¸è¿‡ï¼Œè¦åœ¨ `Closed` åŽé‡æ–°æ‰“开的 Window 注册必须能够产生新实例。singleton 注册,或在åŒä¸€ä¸ªä»å­˜æ´» scope 中é‡å¤ä½¿ç”¨çš„ scoped æ³¨å†Œï¼Œä¼šå†æ¬¡è¿”回已ç»å…³é—­çš„ WPF Window,åŽç»­ `Show` 将失败。`AddWindow` 默认的 transient æ³¨å†Œæ»¡è¶³é‡æ–°åˆ›å»ºè¦æ±‚;使用 scoped Window 时,应用必须让 scope éšå¯¹åº” UI 生命周期一起创建和释放。扩展方法åªè´Ÿè´£æ³¨å†Œï¼Œç»ä¸ä¼šè®¾ç½® Window çš„ `DataContext`。 -`AddPage` 与 `AddContent` åŒæ ·ä¿ç•™æ›´æ—©çš„æ³¨å†Œï¼Œä¹Ÿä¸ä¼šåˆ›å»ºæˆ–设置 View çš„ `DataContext`。 +å½“å‰ `AddPage` 与 `AddContent` 使用 `TryAddSingleton` 注册 View å’Œå¯é€‰çš„ ViewModel,这与 `AddWindow` çš„ `TryAddTransient` 是有æ„çš„ç”Ÿå‘½å‘¨æœŸå·®å¼‚ã€‚å®ƒä»¬åŒæ ·ä¿ç•™æ›´æ—©çš„æ³¨å†Œï¼Œä¹Ÿä¸ä¼šåˆ›å»ºæˆ–设置 View çš„ `DataContext`。 åŒæ³›åž‹æ—  key çš„é‡è½½åªæ³¨å†Œ View å’Œ ViewModelï¼›å•æ³›åž‹åŠ  key çš„é‡è½½æ³¨å†Œ View 与路由别åï¼›åŒæ³›åž‹åŠ  key çš„é‡è½½åŒæ—¶æ³¨å†Œ Viewã€ViewModel 与路由别å。Pageã€Content 与 Dialog çš„ key 空间相互独立,å‡é‡‡ç”¨ ordinalã€åŒºåˆ†å¤§å°å†™çš„æ¯”较;åŒä¸€ä¸ª key å¯ä»¥åˆ†åˆ«ç”¨äºŽä¸‰ç§è·¯ç”±ï¼Œä½†åŒä¸€ç©ºé—´å†…ä¸èƒ½é‡å¤æ³¨å†Œã€‚ @@ -226,7 +226,7 @@ bool closedByKey = dialogService.Close("login"); 泛型与 `Type` é‡è½½åªéœ€è¦æ™®é€š DI 注册,ä¸è¯»å–路由表。字符串é‡è½½å…ˆåœ¨ç‹¬ç«‹çš„ Dialog 路由空间中按 ordinalã€åŒºåˆ†å¤§å°å†™çš„ key 查找 Window 类型,éšåŽä»é€šè¿‡æ™®é€š DI è§£æžå®žä¾‹ï¼›æœªçŸ¥ key 会抛出 `KeyNotFoundException`。Pageã€Content 与 Dialog å¯ä½¿ç”¨ç›¸åŒ key,互ä¸å†²çªã€‚ -`AddWindow` 默认把 Window 注册为 transient,但 `DialogManager` 会在窗å£ä»ç„¶å­˜æ´»ä¸”未关闭时å¤ç”¨å½“å‰å®žä¾‹ã€‚因此,对åŒä¸€ Window 类型é‡å¤ `Show` 会显示或激活当å‰å®žä¾‹ï¼›å³ä½¿å®ƒå½“剿²¡æœ‰ç„¦ç‚¹ï¼Œ`Close` 也能关闭它。窗å£è§¦å‘ `Closed` åŽè®°å½•会移除,下一次 `Show` æ‰ä»Ž DI 创建新的 transient 实例。`Close` åªæŸ¥è¯¢çŽ°æœ‰å®žä¾‹ï¼Œç»ä¸ä¼šä¸ºäº†å…³é—­è€Œåˆ›å»ºçª—å£ï¼›æ²¡æœ‰å—管ç†çš„现有实例时返回 `false`,`Closing` è¢«å–æ¶ˆæ—¶ä¹Ÿè¿”回 `false`。 +`AddWindow` 默认把 Window 注册为 transient,但 `DialogManager` 会在窗å£ä»ç„¶å­˜æ´»ä¸”未关闭时å¤ç”¨å½“å‰å®žä¾‹ã€‚因此,对åŒä¸€ Window 类型é‡å¤ `Show` 会显示或激活当å‰å®žä¾‹ï¼›å³ä½¿å®ƒå½“剿²¡æœ‰ç„¦ç‚¹ï¼Œ`Close` 也能关闭它。窗å£è§¦å‘ `Closed` åŽè®°å½•会移除,下一次 `Show` æ‰ä»Ž DI è§£æžæ–°å®žä¾‹ã€‚默认 transient 注册会产生新的 Window;自定义 singleton 注册或åŒä¸€ä»å­˜æ´» scope 中å¤ç”¨çš„ scoped 注册则会返回已关闭实例,WPF ä¸å…è®¸å†æ¬¡æ˜¾ç¤ºè¯¥å®žä¾‹ã€‚`Close` åªæŸ¥è¯¢çŽ°æœ‰å®žä¾‹ï¼Œç»ä¸ä¼šä¸ºäº†å…³é—­è€Œåˆ›å»ºçª—å£ï¼›æ²¡æœ‰å—管ç†çš„现有实例时返回 `false`,`Closing` è¢«å–æ¶ˆæ—¶ä¹Ÿè¿”回 `false`。 Window 以åŠå®ƒçš„ã€ä¸”与 Window ä¸åŒçš„ `DataContext` 都å¯ä»¥å®žçް `IDialogAware`,两者会按 Windowã€DataContext çš„é¡ºåºæŽ¥æ”¶å‚æ•°å’Œå…³é—­å›žè°ƒã€‚类库ä¸ä¼šè®¾ç½®æˆ–æ›¿æ¢ `DataContext`: @@ -278,7 +278,7 @@ singleton çš„ `PageService`ã€`ContentService` å’Œ `DialogManager` 会æ•获创 因此,在å¯ç”¨ `ValidateScopes` æ—¶ï¼Œä»Žæ ¹å®¹å™¨è§£æž scoped Viewã€ViewModel 或 Window 对象图是无效的;从根容器解æžçš„ disposable transient 对象会由 Microsoft DI ä¿ç•™åˆ°æ ¹å®¹å™¨é‡Šæ”¾ã€‚类库ä¸ä¼šä¸ºå¯¼èˆªæˆ–窗å£åˆ›å»ºã€æŒæœ‰æˆ–释放 scope,因为已显示 UI 的生命周期属于应用。 -éœ€è¦ scoped 或 disposable View/ViewModel/Window 对象图的应用,必须在调用 `RegisterNavigationService()` å‰è¦†ç›–相关导航æœåŠ¡å’Œç®¡ç†å™¨çš„生命周期或解æžç­–略(其 `TryAdd` 注册会ä¿ç•™å…ˆå‰æ³¨å†Œï¼‰ï¼Œä»Žåº”ç”¨æŒæœ‰çš„ scope è§£æžè¿™äº›æœåŠ¡ï¼Œå¹¶è®© scope 的释放时机与对应 UI 生命周期一致。 +éœ€è¦ scoped 或 disposable View/ViewModel/Window 对象图的应用,必须在调用 `RegisterNavigationService()` å‰è¦†ç›–相关导航æœåŠ¡å’Œç®¡ç†å™¨çš„生命周期或解æžç­–略(其 `TryAdd` 注册会ä¿ç•™å…ˆå‰æ³¨å†Œï¼‰ï¼Œä»Žåº”ç”¨æŒæœ‰çš„ scope è§£æžè¿™äº›æœåŠ¡ï¼Œå¹¶è®© scope 的释放时机与对应 UI 生命周期一致。特别是 scoped Windowï¼Œæ¯æ¬¡å…³é—­åŽè‹¥è¿˜è¦é‡æ–°æ‰“开,必须释放旧 scope,并为下一次 UI 生命周期创建新的 scopeï¼Œç¡®ä¿ DI 返回全新的 Window 实例。 ## 区域宿主扩展边界 From ec2ab69dc5895640490eb2de3a5235fbb8484286 Mon Sep 17 00:00:00 2001 From: Junevy Date: Thu, 16 Jul 2026 23:47:34 +0800 Subject: [PATCH 36/42] fix: make dialog callback transactions exception safe --- Services/DialogService.cs | 70 ++++++++-- .../DialogServiceTests.cs | 123 ++++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 52 ++++++++ 3 files changed, 233 insertions(+), 12 deletions(-) diff --git a/Services/DialogService.cs b/Services/DialogService.cs index cf52ed5..0529045 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -16,7 +16,8 @@ public class DialogService : IDialogService private readonly IDialogManager dialogManager; private readonly NavigationRouteRegistry routes; private readonly object subscriptionSyncRoot = new(); - private readonly Dictionary nonModalSubscriptions = new(); + private readonly Dictionary nonModalSubscriptions = + new(WindowReferenceComparer.Instance); private readonly object modalSyncRoot = new(); private readonly HashSet activeModalWindows = new(WindowReferenceComparer.Instance); @@ -119,7 +120,10 @@ private void ShowCore(Window window, DialogParameters? parameters) && priorSubscription != null && IsCurrentWindow(window)) { - TryAttachNonModalSubscription(window, priorSubscription); + TryRestoreNonModalSubscription( + window, + priorSubscription, + subscription.RequestClose); } throw; @@ -187,7 +191,12 @@ private void ShowCore(Window window, DialogParameters? parameters) { ClearRequestClose(awareTargets, requestClose); if (priorSubscription != null && IsCurrentWindow(window)) - TryAttachNonModalSubscription(window, priorSubscription); + { + TryRestoreNonModalSubscription( + window, + priorSubscription, + requestClose); + } throw; } @@ -240,28 +249,42 @@ private void AttachNonModalSubscription( Window window, NonModalSubscription subscription) { - SetRequestClose(subscription.AwareTargets, subscription.RequestClose); - window.Closed += subscription.ClosedHandler; lock (subscriptionSyncRoot) { - nonModalSubscriptions[window] = subscription; + nonModalSubscriptions.Add(window, subscription); } + + window.Closed += subscription.ClosedHandler; + SetRequestClose(subscription.AwareTargets, subscription.RequestClose); } - private bool TryAttachNonModalSubscription( + private bool TryRestoreNonModalSubscription( Window window, - NonModalSubscription subscription) + NonModalSubscription subscription, + Action failedRequestClose) { lock (subscriptionSyncRoot) { if (nonModalSubscriptions.ContainsKey(window)) return false; - SetRequestClose(subscription.AwareTargets, subscription.RequestClose); - window.Closed += subscription.ClosedHandler; nonModalSubscriptions.Add(window, subscription); + } + + try + { + window.Closed += subscription.ClosedHandler; + RestoreRequestClose( + subscription.AwareTargets, + subscription.RequestClose, + failedRequestClose); return true; } + catch + { + RemoveNonModalSubscription(window, subscription); + return false; + } } private NonModalSubscription? RemoveNonModalSubscription(Window window) @@ -365,14 +388,37 @@ private static void NotifyAwareTargets( aware.OnNavigated(parameters); } + private static void RestoreRequestClose( + IEnumerable awareTargets, + Action requestClose, + Action failedRequestClose) + { + foreach (var aware in awareTargets) + { + var currentRequestClose = aware.RequestClose; + if (currentRequestClose == null + || ReferenceEquals(currentRequestClose, failedRequestClose)) + { + aware.RequestClose = requestClose; + } + } + } + private static void ClearRequestClose( IEnumerable awareTargets, Action requestClose) { foreach (var aware in awareTargets) { - if (ReferenceEquals(aware.RequestClose, requestClose)) - aware.RequestClose = null; + try + { + if (ReferenceEquals(aware.RequestClose, requestClose)) + aware.RequestClose = null; + } + catch + { + // Cleanup must not replace the exception from the dialog operation. + } } } diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs index c7610a3..91e9bd0 100644 --- a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -195,6 +195,129 @@ public void Show_AwarenessFailureRestoresPriorLiveSubscription() }); } + [Fact] + public void Show_RollbackRestoresPriorOnlyWithoutOverwritingApplicationCallback() + { + StaTest.Run(() => + { + var viewModel = new ReplacingThrowingAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var priorWindowCallback = window.RequestClose; + var priorViewModelCallback = viewModel.RequestClose; + viewModel.ReplaceAndThrow = true; + + Assert.Throws(() => service.Show()); + + Assert.Same(priorWindowCallback, window.RequestClose); + Assert.NotSame(priorViewModelCallback, viewModel.RequestClose); + Assert.Same(viewModel.Replacement, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + priorWindowCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + Assert.Same(viewModel.Replacement, viewModel.RequestClose); + }); + } + + [Fact] + public void Show_ThrowingRequestCloseSetterRollsBackEarlierTargets() + { + StaTest.Run(() => + { + var viewModel = new ThrowingRequestCloseAwareViewModel + { + ThrowOnEveryNonNullSet = true, + }; + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Show()); + + Assert.Equal("request close setter failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + window.Close(); + }); + } + + [Fact] + public void Show_ThrowingRequestCloseSetterRestoresPriorSubscription() + { + StaTest.Run(() => + { + var viewModel = new ThrowingRequestCloseAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var priorWindowCallback = window.RequestClose; + var priorViewModelCallback = viewModel.RequestClose; + window.Hide(); + viewModel.ThrowOnNextNonNullSet = true; + + var exception = Assert.Throws(() => + service.Show()); + + Assert.Equal("request close setter failed", exception.Message); + Assert.Same(priorWindowCallback, window.RequestClose); + Assert.Same(priorViewModelCallback, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + priorWindowCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_PriorRestoreSetterFailurePreservesOriginalExceptionAndCleansPartialRestore() + { + StaTest.Run(() => + { + var viewModel = new ThrowingRequestCloseAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + window.Hide(); + viewModel.ThrowOnNavigatedAndArmNextNonNullSet = true; + + var exception = Assert.Throws(() => + service.Show()); + + Assert.Equal("dialog awareness failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + window.Close(); + }); + } + + [Fact] + public void Show_NonModalSubscriptionsUseExplicitReferenceIdentityComparer() + { + StaTest.Run(() => + { + using var provider = BuildProvider(); + var service = Assert.IsType( + provider.GetRequiredService()); + var field = typeof(DialogService).GetField( + "nonModalSubscriptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var subscriptions = field!.GetValue(service); + Assert.NotNull(subscriptions); + var comparer = subscriptions!.GetType() + .GetProperty("Comparer")! + .GetValue(subscriptions); + + Assert.NotNull(comparer); + Assert.NotSame(EqualityComparer.Default, comparer); + }); + } + [Fact] public void Show_ReentrantSuccessfulReplacementSurvivesOuterRollback() { diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index 56d9d1a..1468ca6 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -128,6 +128,58 @@ public override void OnNavigated(DialogParameters? parameters) } } +public sealed class ReplacingThrowingAwareDialogViewModel : AwareDialogViewModel +{ + public bool ReplaceAndThrow { get; set; } + + public Action Replacement { get; } = _ => { }; + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + if (!ReplaceAndThrow) + return; + + RequestClose = Replacement; + throw new InvalidOperationException("dialog awareness failed"); + } +} + +public sealed class ThrowingRequestCloseAwareViewModel : IDialogAware +{ + private Action? requestClose; + + public bool ThrowOnEveryNonNullSet { get; set; } + + public bool ThrowOnNextNonNullSet { get; set; } + + public bool ThrowOnNavigatedAndArmNextNonNullSet { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + if (value != null && (ThrowOnEveryNonNullSet || ThrowOnNextNonNullSet)) + { + ThrowOnNextNonNullSet = false; + throw new InvalidOperationException("request close setter failed"); + } + + requestClose = value; + } + } + + public void OnNavigated(DialogParameters? parameters) + { + if (!ThrowOnNavigatedAndArmNextNonNullSet) + return; + + ThrowOnNextNonNullSet = true; + throw new InvalidOperationException("dialog awareness failed"); + } +} + public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel { public Action Replacement { get; } = _ => { }; From 7b757fcdef9c45647d37a180b2a53b0775113b7c Mon Sep 17 00:00:00 2001 From: Junevy Date: Fri, 17 Jul 2026 00:00:47 +0800 Subject: [PATCH 37/42] fix: complete dialog callback cleanup transactions --- Services/DialogService.cs | 112 +++++++++++++++--- .../DialogServiceTests.cs | 111 ++++++++++++++++- Tests/SimpleNavigation.Tests/TestTypes.cs | 45 +++++++ 3 files changed, 249 insertions(+), 19 deletions(-) diff --git a/Services/DialogService.cs b/Services/DialogService.cs index 0529045..0afd122 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -3,6 +3,7 @@ using SimpleNavigation.Interface.Awares; using SimpleNavigation.Interface.Managers; using SimpleNavigation.Interface.Services; +using System.Runtime.ExceptionServices; using System.Runtime.CompilerServices; using System.Windows; @@ -101,7 +102,9 @@ private void ShowCore(Window window, DialogParameters? parameters) try { - AttachNonModalSubscription(window, subscription); + if (!AttachNonModalSubscription(window, subscription)) + return; + NotifyAwareTargets(subscription.AwareTargets, parameters); if (!IsCurrentWindow(window)) @@ -115,7 +118,10 @@ private void ShowCore(Window window, DialogParameters? parameters) catch { var removedSubscription = - RemoveNonModalSubscription(window, subscription); + RemoveNonModalSubscription( + window, + subscription, + suppressCallbackExceptions: true); if (removedSubscription && priorSubscription != null && IsCurrentWindow(window)) @@ -176,6 +182,7 @@ private void ShowCore(Window window, DialogParameters? parameters) } }; + var operationFailed = false; try { SetRequestClose(awareTargets, requestClose); @@ -189,7 +196,11 @@ private void ShowCore(Window window, DialogParameters? parameters) } catch { - ClearRequestClose(awareTargets, requestClose); + operationFailed = true; + ClearRequestClose( + awareTargets, + requestClose, + suppressExceptions: true); if (priorSubscription != null && IsCurrentWindow(window)) { TryRestoreNonModalSubscription( @@ -202,7 +213,8 @@ private void ShowCore(Window window, DialogParameters? parameters) } finally { - ClearRequestClose(awareTargets, requestClose); + if (!operationFailed) + ClearRequestClose(awareTargets, requestClose); } } @@ -245,7 +257,7 @@ private NonModalSubscription CreateNonModalSubscription(Window window) return subscription; } - private void AttachNonModalSubscription( + private bool AttachNonModalSubscription( Window window, NonModalSubscription subscription) { @@ -255,7 +267,7 @@ private void AttachNonModalSubscription( } window.Closed += subscription.ClosedHandler; - SetRequestClose(subscription.AwareTargets, subscription.RequestClose); + return SetRequestClose(window, subscription); } private bool TryRestoreNonModalSubscription( @@ -274,20 +286,35 @@ private bool TryRestoreNonModalSubscription( try { window.Closed += subscription.ClosedHandler; - RestoreRequestClose( + if (!RestoreRequestClose( + window, + subscription, subscription.AwareTargets, subscription.RequestClose, - failedRequestClose); + failedRequestClose)) + { + RemoveNonModalSubscription( + window, + subscription, + suppressCallbackExceptions: true); + return false; + } + return true; } catch { - RemoveNonModalSubscription(window, subscription); + RemoveNonModalSubscription( + window, + subscription, + suppressCallbackExceptions: true); return false; } } - private NonModalSubscription? RemoveNonModalSubscription(Window window) + private NonModalSubscription? RemoveNonModalSubscription( + Window window, + bool suppressCallbackExceptions = false) { NonModalSubscription? subscription; lock (subscriptionSyncRoot) @@ -299,13 +326,17 @@ private bool TryRestoreNonModalSubscription( } window.Closed -= subscription.ClosedHandler; - ClearRequestClose(subscription.AwareTargets, subscription.RequestClose); + ClearRequestClose( + subscription.AwareTargets, + subscription.RequestClose, + suppressCallbackExceptions); return subscription; } private bool RemoveNonModalSubscription( Window window, - NonModalSubscription expectedSubscription) + NonModalSubscription expectedSubscription, + bool suppressCallbackExceptions = false) { lock (subscriptionSyncRoot) { @@ -319,10 +350,24 @@ private bool RemoveNonModalSubscription( } window.Closed -= expectedSubscription.ClosedHandler; - ClearRequestClose(expectedSubscription.AwareTargets, expectedSubscription.RequestClose); + ClearRequestClose( + expectedSubscription.AwareTargets, + expectedSubscription.RequestClose, + suppressCallbackExceptions); return true; } + private bool IsExpectedNonModalSubscription( + Window window, + NonModalSubscription expectedSubscription) + { + lock (subscriptionSyncRoot) + { + return nonModalSubscriptions.TryGetValue(window, out var currentSubscription) + && ReferenceEquals(currentSubscription, expectedSubscription); + } + } + private bool IsModalActive(Window window) { lock (modalSyncRoot) @@ -380,6 +425,25 @@ private static void SetRequestClose( aware.RequestClose = requestClose; } + private bool SetRequestClose( + Window window, + NonModalSubscription subscription) + { + foreach (var aware in subscription.AwareTargets) + { + aware.RequestClose = subscription.RequestClose; + if (!IsExpectedNonModalSubscription(window, subscription)) + { + ClearRequestClose( + subscription.AwareTargets, + subscription.RequestClose); + return false; + } + } + + return true; + } + private static void NotifyAwareTargets( IEnumerable awareTargets, DialogParameters? parameters) @@ -388,7 +452,9 @@ private static void NotifyAwareTargets( aware.OnNavigated(parameters); } - private static void RestoreRequestClose( + private bool RestoreRequestClose( + Window window, + NonModalSubscription subscription, IEnumerable awareTargets, Action requestClose, Action failedRequestClose) @@ -396,18 +462,27 @@ private static void RestoreRequestClose( foreach (var aware in awareTargets) { var currentRequestClose = aware.RequestClose; + if (!IsExpectedNonModalSubscription(window, subscription)) + return false; + if (currentRequestClose == null || ReferenceEquals(currentRequestClose, failedRequestClose)) { aware.RequestClose = requestClose; + if (!IsExpectedNonModalSubscription(window, subscription)) + return false; } } + + return true; } private static void ClearRequestClose( IEnumerable awareTargets, - Action requestClose) + Action requestClose, + bool suppressExceptions = false) { + ExceptionDispatchInfo? cleanupException = null; foreach (var aware in awareTargets) { try @@ -415,11 +490,14 @@ private static void ClearRequestClose( if (ReferenceEquals(aware.RequestClose, requestClose)) aware.RequestClose = null; } - catch + catch (Exception exception) { - // Cleanup must not replace the exception from the dialog operation. + cleanupException ??= ExceptionDispatchInfo.Capture(exception); } } + + if (!suppressExceptions) + cleanupException?.Throw(); } private static void ValidateWindowType(Type targetType) diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs index 91e9bd0..5358025 100644 --- a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading; using System.Windows; using System.Windows.Threading; @@ -313,8 +314,95 @@ public void Show_NonModalSubscriptionsUseExplicitReferenceIdentityComparer() .GetProperty("Comparer")! .GetValue(subscriptions); - Assert.NotNull(comparer); - Assert.NotSame(EqualityComparer.Default, comparer); + var referenceComparer = Assert.IsAssignableFrom>(comparer); + var first = new Window(); + var second = new Window(); + Assert.Equal("WindowReferenceComparer", comparer!.GetType().Name); + Assert.True(referenceComparer.Equals(first, first)); + Assert.False(referenceComparer.Equals(first, second)); + Assert.Equal(RuntimeHelpers.GetHashCode(first), referenceComparer.GetHashCode(first)); + first.Close(); + second.Close(); + }); + } + + [Fact] + public void Show_RequestCloseSetterSynchronousCloseStopsRemainingCallbackInstallation() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ClosingRequestCloseAwareWindow + { + CloseOnNonNullSet = true, + DataContext = viewModel, + }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + provider.GetRequiredService() + .Show(); + + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(ClosingRequestCloseAwareWindow))); + }); + } + + [Fact] + public void Show_NormalClosedCleanupContinuesAndPropagatesFirstCallbackException() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ThrowingClearAwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + provider.GetRequiredService() + .Show(); + window.ThrowOnNullSet = true; + var closedHandler = GetNonModalClosedHandler(provider, window); + + var exception = Assert.Throws(() => + closedHandler(window, EventArgs.Empty)); + + Assert.Equal("request close cleanup failed", exception.Message); + Assert.NotNull(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + window.ThrowOnNullSet = false; + window.RequestClose = null; + window.Close(); + }); + } + + [Fact] + public void ShowDialog_NormalFinallyCleanupContinuesAndPropagatesFirstCallbackException() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ThrowingClearAwareWindow + { + DataContext = viewModel, + ThrowOnNullSet = true, + }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService() + .ShowDialog()); + + Assert.Equal("request close cleanup failed", exception.Message); + Assert.NotNull(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(ThrowingClearAwareWindow))); + window.ThrowOnNullSet = false; + window.RequestClose = null; }); } @@ -906,4 +994,23 @@ private static int GetNonModalSubscriptionCount(IServiceProvider provider) field!.GetValue(service)); return subscriptions.Count; } + + private static EventHandler GetNonModalClosedHandler( + IServiceProvider provider, + Window window) + { + var service = Assert.IsType( + provider.GetRequiredService()); + var field = typeof(DialogService).GetField( + "nonModalSubscriptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var subscriptions = Assert.IsAssignableFrom( + field!.GetValue(service)); + var subscription = subscriptions[window]; + Assert.NotNull(subscription); + var property = subscription!.GetType().GetProperty("ClosedHandler"); + Assert.NotNull(property); + return Assert.IsType(property!.GetValue(subscription)); + } } diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index 1468ca6..424c69d 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -180,6 +180,51 @@ public void OnNavigated(DialogParameters? parameters) } } +public sealed class ClosingRequestCloseAwareWindow : Window, IDialogAware +{ + private Action? requestClose; + + public bool CloseOnNonNullSet { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + requestClose = value; + if (value != null && CloseOnNonNullSet) + Close(); + } + } + + public void OnNavigated(DialogParameters? parameters) + { + } +} + +public sealed class ThrowingClearAwareWindow : Window, IDialogAware +{ + private Action? requestClose; + + public bool ThrowOnNullSet { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + if (value == null && ThrowOnNullSet) + throw new InvalidOperationException("request close cleanup failed"); + + requestClose = value; + } + } + + public void OnNavigated(DialogParameters? parameters) + { + } +} + public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel { public Action Replacement { get; } = _ => { }; From bd4d2f0ede146df0343296894c408472b5ac4af5 Mon Sep 17 00:00:00 2001 From: Junevy Date: Fri, 17 Jul 2026 00:16:55 +0800 Subject: [PATCH 38/42] fix: guard dialog subscription cleanup reentrancy --- Services/DialogService.cs | 16 ++++++ .../DialogServiceTests.cs | 50 +++++++++++++++++++ Tests/SimpleNavigation.Tests/TestTypes.cs | 25 ++++++++++ 3 files changed, 91 insertions(+) diff --git a/Services/DialogService.cs b/Services/DialogService.cs index 0afd122..ef896d2 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -98,6 +98,9 @@ private void ShowCore(Window window, DialogParameters? parameters) } var priorSubscription = RemoveNonModalSubscription(window); + if (!IsCurrentWindow(window) || HasNonModalSubscription(window)) + return; + var subscription = CreateNonModalSubscription(window); try @@ -162,6 +165,8 @@ private void ShowCore(Window window, DialogParameters? parameters) DialogParameters? parameters) { var priorSubscription = RemoveNonModalSubscription(window); + if (!IsCurrentWindow(window) || HasNonModalSubscription(window)) + return null; DialogParameters? result = null; var awareTargets = GetAwareTargets(window); @@ -263,6 +268,9 @@ private bool AttachNonModalSubscription( { lock (subscriptionSyncRoot) { + if (nonModalSubscriptions.ContainsKey(window)) + return false; + nonModalSubscriptions.Add(window, subscription); } @@ -368,6 +376,14 @@ private bool IsExpectedNonModalSubscription( } } + private bool HasNonModalSubscription(Window window) + { + lock (subscriptionSyncRoot) + { + return nonModalSubscriptions.ContainsKey(window); + } + } + private bool IsModalActive(Window window) { lock (modalSyncRoot) diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs index 5358025..b9c7acf 100644 --- a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -406,6 +406,56 @@ public void ShowDialog_NormalFinallyCleanupContinuesAndPropagatesFirstCallbackEx }); } + [Fact] + public void Show_PriorCleanupSetterSynchronousCloseDoesNotAttachToClosedWindow() + { + StaTest.Run(() => + { + var viewModel = new CleanupActionAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + viewModel.CleanupAction = window.Close; + + service.Show(); + + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(AwareWindow))); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_PriorCleanupSetterReentrantShowPreservesReentrantSubscription() + { + StaTest.Run(() => + { + var viewModel = new CleanupActionAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + Action? reentrantCallback = null; + viewModel.CleanupAction = () => + { + service.Show(); + reentrantCallback = window.RequestClose; + }; + + service.Show(); + + Assert.NotNull(reentrantCallback); + Assert.Same(reentrantCallback, window.RequestClose); + Assert.Same(reentrantCallback, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + reentrantCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + [Fact] public void Show_ReentrantSuccessfulReplacementSurvivesOuterRollback() { diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs index 424c69d..6ed8524 100644 --- a/Tests/SimpleNavigation.Tests/TestTypes.cs +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -225,6 +225,31 @@ public void OnNavigated(DialogParameters? parameters) } } +public sealed class CleanupActionAwareViewModel : IDialogAware +{ + private Action? requestClose; + + public Action? CleanupAction { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + var cleanupAction = value == null && requestClose != null + ? CleanupAction + : null; + CleanupAction = null; + requestClose = value; + cleanupAction?.Invoke(); + } + } + + public void OnNavigated(DialogParameters? parameters) + { + } +} + public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel { public Action Replacement { get; } = _ => { }; From 973dac7967641f401901798e061eaac80cdb66ee Mon Sep 17 00:00:00 2001 From: Junevy Date: Fri, 17 Jul 2026 00:52:29 +0800 Subject: [PATCH 39/42] Feat: Add Singleton & Transient life circle in Extentsions. --- Extensions/NavigationExtensions.cs | 67 +++++++++++++++++-- Services/Region.cs | 12 ++-- SimpleNavigation.csproj | 2 +- .../ContentServiceTests.cs | 4 +- .../NavigationExtensionsTests.cs | 52 +++++++------- .../PageServiceTests.cs | 2 +- 6 files changed, 96 insertions(+), 43 deletions(-) diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index 8eab5fe..55e3f0f 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -27,7 +27,7 @@ public static IServiceCollection RegisterNavigationService( return serviceCollection; } - public static IServiceCollection AddPage(this IServiceCollection services, string key) + public static IServiceCollection AddSingletonPage(this IServiceCollection services, string key) where TPage : Page { AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); @@ -35,7 +35,15 @@ public static IServiceCollection AddPage(this IServiceCollection services return services; } - public static IServiceCollection AddPage(this IServiceCollection services) + public static IServiceCollection AddTransientPage(this IServiceCollection services, string key) + where TPage : Page + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonPage(this IServiceCollection services) where TPage : Page where TViewModel : class { services.TryAddSingleton(); @@ -43,7 +51,15 @@ public static IServiceCollection AddPage(this IServiceCollect return services; } - public static IServiceCollection AddPage(this IServiceCollection services, string key) + public static IServiceCollection AddTransientPage(this IServiceCollection services) + where TPage : Page where TViewModel : class + { + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonPage(this IServiceCollection services, string key) where TPage : Page where TViewModel : class { AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); @@ -52,7 +68,16 @@ public static IServiceCollection AddPage(this IServiceCollect return services; } - public static IServiceCollection AddContent(this IServiceCollection services,string key) + public static IServiceCollection AddTransientPage(this IServiceCollection services, string key) + where TPage : Page where TViewModel : class + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonContent(this IServiceCollection services, string key) where TView : FrameworkElement { ValidateContentType(typeof(TView)); @@ -61,7 +86,16 @@ public static IServiceCollection AddContent(this IServiceCollection servi return services; } - public static IServiceCollection AddContent(this IServiceCollection services) + public static IServiceCollection AddTransientContent(this IServiceCollection services, string key) + where TView : FrameworkElement + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonContent(this IServiceCollection services) where TView : FrameworkElement where TViewModel : class { ValidateContentType(typeof(TView)); @@ -70,7 +104,16 @@ public static IServiceCollection AddContent(this IServiceColl return services; } - public static IServiceCollection AddContent(this IServiceCollection services, string key) + public static IServiceCollection AddTransientContent(this IServiceCollection services) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonContent(this IServiceCollection services, string key) where TView : FrameworkElement where TViewModel : class { ValidateContentType(typeof(TView)); @@ -80,6 +123,16 @@ public static IServiceCollection AddContent(this IServiceColl return services; } + public static IServiceCollection AddTransientContent(this IServiceCollection services, string key) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + public static IServiceCollection AddWindow(this IServiceCollection services, string key) where TWindow : Window { @@ -105,7 +158,7 @@ public static IServiceCollection AddWindow(this IServiceCol return services; } - private static void AddRoute(IServiceCollection services, NavigationRouteKind kind, string key, Type targetType) + private static void AddRoute(IServiceCollection services, NavigationRouteKind kind, string key, Type targetType) { if (string.IsNullOrWhiteSpace(key)) { diff --git a/Services/Region.cs b/Services/Region.cs index aa90fbf..508b98a 100644 --- a/Services/Region.cs +++ b/Services/Region.cs @@ -320,13 +320,13 @@ private static void ActivateHostUnderPublicationGate(FrameworkElement host) private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) { - if (sender is not FrameworkElement host) - return; + //if (sender is not FrameworkElement host) + // return; - lock (PublicationGate) - { - DeactivateHostUnderPublicationGate(host); - } + //lock (PublicationGate) + //{ + // DeactivateHostUnderPublicationGate(host); + //} } private static void DeactivateHostUnderPublicationGate(FrameworkElement host) diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index f881e58..2af4039 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,7 +8,7 @@ 13 true Junevy.SimpleNavigation - 2.0.1 + 2.2.0 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. $(DefaultItemExcludesInProjectFolder);Tests/** diff --git a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs index 6b26ac4..835513d 100644 --- a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs @@ -56,7 +56,7 @@ public void StringNavigationUsesCaseSensitiveRouteThenOrdinaryDi() var services = new ServiceCollection(); services.RegisterNavigationService(); services.AddSingleton(expected); - services.AddContent("content"); + services.AddSingletonContent("content"); using var provider = services.BuildServiceProvider(); var host = RegisterContentHost(provider, "main"); var service = provider.GetRequiredService(); @@ -180,7 +180,7 @@ public void DoubleGenericRegistrationWithoutKeyDoesNotCreateARoute() { var services = new ServiceCollection(); services.RegisterNavigationService(); - services.AddContent(); + services.AddSingletonContent(); using var provider = services.BuildServiceProvider(); var host = RegisterContentHost(provider, "main"); diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs index 16f61bf..3a519ef 100644 --- a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -20,8 +20,8 @@ public void SingleGenericKeyOverloads_RegisterTransientViewsAndCoreServices() { var services = new ServiceCollection(); services.RegisterNavigationService(); - services.AddPage("page"); - services.AddContent("content"); + services.AddTransientPage("page"); + services.AddTransientContent("content"); using var provider = services.BuildServiceProvider(); Assert.NotSame( @@ -98,10 +98,10 @@ public void RegisterNavigationService_PreservesEarlierCoreRegistrations() public void RouteRegistry_MapsRoutesAddedBeforeAndAfterCoreRegistration() { var services = new ServiceCollection(); - services.AddPage("shared"); + services.AddSingletonPage("shared"); services.RegisterNavigationService(); - services.AddContent("shared"); - services.AddPage("second"); + services.AddSingletonContent("shared"); + services.AddSingletonPage("second"); using var provider = services.BuildServiceProvider(); var registry = GetRouteRegistry(services, provider); @@ -120,8 +120,8 @@ public void RouteRegistry_MapsRoutesAddedBeforeAndAfterCoreRegistration() public void RouteRegistry_UsesOrdinalCaseSensitiveKeys() { var services = new ServiceCollection(); - services.AddPage("main"); - services.AddPage("Main"); + services.AddSingletonPage("main"); + services.AddSingletonPage("Main"); services.RegisterNavigationService(); using var provider = services.BuildServiceProvider(); var registry = GetRouteRegistry(services, provider); @@ -171,8 +171,8 @@ public void DoubleGenericOverloads_RegisterViewAndViewModelWithoutSettingDataCon StaTest.Run(() => { var services = new ServiceCollection(); - services.AddPage(); - services.AddContent(); + services.AddTransientPage(); + services.AddTransientContent(); using var provider = services.BuildServiceProvider(); var page = provider.GetRequiredService(); @@ -199,8 +199,8 @@ public void RegistrationHelpers_PreserveEarlierSingletonRegistrations() services.AddSingleton(content); services.AddSingleton(viewModel); - services.AddPage("page"); - services.AddContent("content"); + services.AddSingletonPage("page"); + services.AddSingletonContent("content"); using var provider = services.BuildServiceProvider(); Assert.Same(page, provider.GetRequiredService()); @@ -213,11 +213,11 @@ public void RegistrationHelpers_PreserveEarlierSingletonRegistrations() public void DuplicateKeyWithinOneCategory_IsRejectedUsingOrdinalMatching() { var services = new ServiceCollection(); - services.AddPage("main"); - services.AddPage("Main"); + services.AddSingletonPage("main"); + services.AddSingletonPage("Main"); var exception = Assert.Throws( - () => services.AddPage("main")); + () => services.AddSingletonPage("main")); Assert.Equal("key", exception.ParamName); } @@ -226,10 +226,10 @@ public void DuplicateKeyWithinOneCategory_IsRejectedUsingOrdinalMatching() public void DuplicateContentKey_IsRejected() { var services = new ServiceCollection(); - services.AddContent("main"); + services.AddSingletonContent("main"); var exception = Assert.Throws( - () => services.AddContent("main")); + () => services.AddSingletonContent("main")); Assert.Equal("key", exception.ParamName); } @@ -239,8 +239,8 @@ public void SameKeyAcrossPageAndContentCategories_IsAllowed() { var services = new ServiceCollection(); - services.AddPage("main"); - services.AddContent("main"); + services.AddSingletonPage("main"); + services.AddSingletonContent("main"); } [Fact] @@ -248,10 +248,10 @@ public void ContentRegistration_RejectsPageAndWindowTypes() { var services = new ServiceCollection(); - Assert.Throws(() => services.AddContent("page")); - Assert.Throws(() => services.AddContent("window")); - Assert.Throws(() => services.AddContent()); - Assert.Throws(() => services.AddContent("window-vm")); + Assert.Throws(() => services.AddSingletonContent("page")); + Assert.Throws(() => services.AddSingletonContent("window")); + Assert.Throws(() => services.AddSingletonContent()); + Assert.Throws(() => services.AddSingletonContent("window-vm")); } [Theory] @@ -261,7 +261,7 @@ public void ContentRegistration_RejectsPageAndWindowTypes() public void InvalidRouteKey_IsRejected(string? key) { var exception = Assert.Throws( - () => new ServiceCollection().AddPage(key!)); + () => new ServiceCollection().AddSingletonPage(key!)); Assert.Equal("key", exception.ParamName); } @@ -273,7 +273,7 @@ public void InvalidRouteKey_IsRejected(string? key) public void InvalidContentRouteKey_IsRejected(string? key) { var exception = Assert.Throws( - () => new ServiceCollection().AddContent(key!)); + () => new ServiceCollection().AddSingletonContent(key!)); Assert.Equal("key", exception.ParamName); } @@ -364,8 +364,8 @@ public void DialogRegistration_AddWindowPreservesEarlierSingletonRegistrations() public void DialogRoutes_UseOrdinalCaseSensitiveKeysAndRejectDuplicatesWithinDialogOnly() { var services = new ServiceCollection(); - services.AddPage("main"); - services.AddContent("main"); + services.AddSingletonPage("main"); + services.AddSingletonContent("main"); services.AddWindow("main"); services.AddWindow("Main"); services.RegisterNavigationService(); diff --git a/Tests/SimpleNavigation.Tests/PageServiceTests.cs b/Tests/SimpleNavigation.Tests/PageServiceTests.cs index d0e943b..47c3e5f 100644 --- a/Tests/SimpleNavigation.Tests/PageServiceTests.cs +++ b/Tests/SimpleNavigation.Tests/PageServiceTests.cs @@ -43,7 +43,7 @@ public void StringNavigationUsesCaseSensitiveRouteThenOrdinaryDi() var services = new ServiceCollection(); services.RegisterNavigationService(); services.AddSingleton(expected); - services.AddPage("first"); + services.AddSingletonPage("first"); using var provider = services.BuildServiceProvider(); var frame = RegisterFrame(provider, "main"); var service = provider.GetRequiredService(); From d032d12e1ddf900b199f120fd23d8904aafdfe51 Mon Sep 17 00:00:00 2001 From: Junevy Date: Fri, 17 Jul 2026 01:15:33 +0800 Subject: [PATCH 40/42] Add: Region unloaded evnet --- Services/Region.cs | 12 ++++++------ SimpleNavigation.csproj | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Services/Region.cs b/Services/Region.cs index 508b98a..aa90fbf 100644 --- a/Services/Region.cs +++ b/Services/Region.cs @@ -320,13 +320,13 @@ private static void ActivateHostUnderPublicationGate(FrameworkElement host) private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) { - //if (sender is not FrameworkElement host) - // return; + if (sender is not FrameworkElement host) + return; - //lock (PublicationGate) - //{ - // DeactivateHostUnderPublicationGate(host); - //} + lock (PublicationGate) + { + DeactivateHostUnderPublicationGate(host); + } } private static void DeactivateHostUnderPublicationGate(FrameworkElement host) diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index 2af4039..489aab0 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,7 +8,7 @@ 13 true Junevy.SimpleNavigation - 2.2.0 + 2.2.1 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. $(DefaultItemExcludesInProjectFolder);Tests/** From 4ad01d80ef7dbcc61cc5f93013e795f0cc92d2f0 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 18 Jul 2026 06:11:05 +0800 Subject: [PATCH 41/42] Feat: Add TabControl adapter. --- .../Adapters/ContentControlRegionAdapter.cs | 2 +- Common/Adapters/RegionHostAdapter.cs | 3 ++ Common/Adapters/TabControlRegionAdapter.cs | 42 +++++++++++++++++++ SimpleNavigation.csproj | 2 +- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 Common/Adapters/TabControlRegionAdapter.cs diff --git a/Common/Adapters/ContentControlRegionAdapter.cs b/Common/Adapters/ContentControlRegionAdapter.cs index c632970..462bf57 100644 --- a/Common/Adapters/ContentControlRegionAdapter.cs +++ b/Common/Adapters/ContentControlRegionAdapter.cs @@ -9,7 +9,7 @@ internal sealed class ContentControlRegionAdapter : IContentRegionHostAdapter public RegionHostKind Kind => RegionHostKind.Content; public bool CanHandle(FrameworkElement region) - => region is ContentControl && region is not Frame; + => region is ContentControl && region is not Frame && region is not TabControl; public void Present(FrameworkElement host, FrameworkElement content) { diff --git a/Common/Adapters/RegionHostAdapter.cs b/Common/Adapters/RegionHostAdapter.cs index fdc0ee2..8c1a6ea 100644 --- a/Common/Adapters/RegionHostAdapter.cs +++ b/Common/Adapters/RegionHostAdapter.cs @@ -19,8 +19,11 @@ internal static class RegionHostAdapterResolver { private static readonly IRegionHostAdapter[] HostAdapters = { + new FrameRegionAdapter(), + new TabControlRegionAdapter(), new ContentControlRegionAdapter(), + }; /// diff --git a/Common/Adapters/TabControlRegionAdapter.cs b/Common/Adapters/TabControlRegionAdapter.cs new file mode 100644 index 0000000..22c3961 --- /dev/null +++ b/Common/Adapters/TabControlRegionAdapter.cs @@ -0,0 +1,42 @@ +using SimpleNavigation.Interface.Adapters; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common.Adapters +{ + internal class TabControlRegionAdapter : IContentRegionHostAdapter + { + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) => region is TabControl; + + public void Present(FrameworkElement host, FrameworkElement content) + { + if (host is not TabControl tabControl) + { + throw new ArgumentException( + $"Host type '{host.GetType().FullName}' must be a TabControl.", + nameof(host)); + } + + host.Dispatcher.VerifyAccess(); + + if (tabControl.ItemsSource == null) + { + tabControl.Items.Add(content); + } + //else + //{ + // if (tabControl.ItemsSource is IList list) + // { + // list.Add(list); + // } + + // if (tabControl.ItemsSource is IEnumerable enums) + // { + // enums. + // } + //} + } + } +} diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index 489aab0..8684671 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,7 +8,7 @@ 13 true Junevy.SimpleNavigation - 2.2.1 + 2.2.2 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. $(DefaultItemExcludesInProjectFolder);Tests/** From 01d434651aaf1abb03468bc5d21a8d7478bbcedc Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 18 Jul 2026 23:44:17 +0800 Subject: [PATCH 42/42] Fix: Fix some bugs of TabControl Adapter --- Common/Adapters/TabControlRegionAdapter.cs | 42 +++++++++++++++------- SimpleNavigation.csproj | 2 +- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/Common/Adapters/TabControlRegionAdapter.cs b/Common/Adapters/TabControlRegionAdapter.cs index 22c3961..243859c 100644 --- a/Common/Adapters/TabControlRegionAdapter.cs +++ b/Common/Adapters/TabControlRegionAdapter.cs @@ -1,4 +1,6 @@ using SimpleNavigation.Interface.Adapters; +using System.Collections; +using System.Reflection; using System.Windows; using System.Windows.Controls; @@ -12,7 +14,7 @@ internal class TabControlRegionAdapter : IContentRegionHostAdapter public void Present(FrameworkElement host, FrameworkElement content) { - if (host is not TabControl tabControl) + if (host is not TabControl tabControl || content is not UserControl view) { throw new ArgumentException( $"Host type '{host.GetType().FullName}' must be a TabControl.", @@ -25,18 +27,32 @@ public void Present(FrameworkElement host, FrameworkElement content) { tabControl.Items.Add(content); } - //else - //{ - // if (tabControl.ItemsSource is IList list) - // { - // list.Add(list); - // } - - // if (tabControl.ItemsSource is IEnumerable enums) - // { - // enums. - // } - //} + else + { + if (tabControl.ItemsSource is IList list) + { + //list.Add(new TabItem() { Header = "new tab", Content = view}); + if (list.Count <= 0) + throw new ArgumentException("The tab control must be keep at least 1 item"); + + var targetType = list[0]?.GetType(); + object instance = Activator.CreateInstance(targetType); + if (instance != null) + { + FieldInfo contentFi = targetType.GetField("ContentProperty", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + DependencyProperty contentDp = (DependencyProperty)contentFi.GetValue(null); + + FieldInfo headerFi = targetType.GetField("HeaderProperty", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + DependencyProperty headerDp = (DependencyProperty)headerFi.GetValue(null); + + var header = content.GetType().Name.Replace("View", ""); + + ((DependencyObject)instance).SetValue(headerDp, header); + ((DependencyObject)instance).SetValue(contentDp, content); + list.Add(instance); + } + } + } } } } diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index 8684671..6b4a25c 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,7 +8,7 @@ 13 true Junevy.SimpleNavigation - 2.2.2 + 2.3.1 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. $(DefaultItemExcludesInProjectFolder);Tests/**