From 2407cae60956d903e915cf8a2ea658286fd81a21 Mon Sep 17 00:00:00 2001 From: Junevy Date: Sat, 11 Jul 2026 22:38:20 +0800 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 @@ -锘縰sing 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 @@ -锘縰sing 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 @@ -锘縰sing System.Windows; -using System.Windows.Controls; - -namespace SimpleNavigation.Services -{ - /// - /// 缁存姢鍖哄煙鍔熻兘 - /// - public static class RegionService - { - // 鍏佽鍦╔AML涓娇鐢≧egionService娉ㄥ唽瀵艰埅鍖哄煙锛屾敞鍐屽畬鎴愬悗浼氳Е鍙浜嬩欢锛屽鑸湇鍔′細璁㈤槄璇ヤ簨浠朵互瀹屾垚鍖哄煙鐨勬敞鍐屻 - 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); - } - - // 浣跨敤闄勫姞灞炴х殑鏂瑰紡鍏佽鍦╔AML涓负鎺т欢鎸囧畾RegionName锛孯egionService浼氱洃鍚灞炴х殑鍙樺寲浠ュ畬鎴愬鑸尯鍩熺殑娉ㄥ唽銆 - 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/21] 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/21] 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/21] 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`锛屾敮鎸佹ā鎬/闈炴ā鎬佺獥鍙c佸弬鏁颁紶閫掍笌缁撴灉杩斿洖銆 +- `RegionManager`锛氱粺涓绠$悊 XAML 澹版槑鎴栦唬鐮佹敞鍐岀殑鍛藉悕鍖哄煙锛屽苟浠ュ急寮曠敤淇濆瓨鍖哄煙瀹夸富銆 -SimpleNavigation 鏄竴涓彈 Prism 鍚彂鐨 WPF 瀵艰埅搴擄紝浣嗗幓闄や簡 Prism 鐨勫鏉傛э紝浠呬繚鐣欐渶鏍稿績鐨勫鑸兘鍔涖傛暣涓鏋朵粎鍖呭惈 11 涓 C# 婧愭枃浠讹紝闆堕澶栦緷璧栵紙鍙緷璧栧井杞畼鏂圭殑 DI 瀹瑰櫒锛夛紝鎻愪緵涓夊ぇ鏍稿績鍔熻兘锛 - -- **鍖哄煙锛圧egion锛夐〉闈㈠鑸** 鈥 鍦ㄥ懡鍚 `Frame` 鍖哄煙鍐呰繘琛 `Page` 鐨勫鑸笌鍥為 -- **绐楀彛锛圖ialog锛夌鐞** 鈥 鎵撳紑 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+)锛孋# 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 # 绐楀彛鐢熷懡鍛ㄦ湡绠$悊锛學eakReference 缂撳瓨闃叉鍐呭瓨娉勬紡 -鈹 鈹斺攢鈹 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(); - // 娉ㄥ唽浣犵殑椤甸潰銆佺獥鍙e拰 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 娉ㄥ唽瀵艰埅鍖哄煙锛圧egion锛 +`AddPage` 涓 `AddContent` 浣跨敤 `TryAddTransient` 娉ㄥ唽 View 鍜屽彲閫夌殑 ViewModel锛屼笉浼氳鐩栧湪瀹冧滑涔嬪墠娣诲姞鐨勬敞鍐屻傞渶瑕佽嚜瀹氫箟鐢熷懡鍛ㄦ湡鎴栧伐鍘傛椂锛屽簲鍏堜娇鐢ㄦ爣鍑 DI 鏂规硶娉ㄥ唽瀵瑰簲鏈嶅姟銆傝繖浜涙墿灞曟柟娉曚笉浼氬垱寤烘垨璁剧疆 View 鐨 `DataContext`銆 -鍦 XAML 涓氳繃闄勫姞灞炴у0鏄庡鑸尯鍩燂細 +鍙湁甯 `string key` 鐨勯噸杞戒細娉ㄥ唽璺敱鍒悕銆侾age 涓 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 瑙f瀽娉涘瀷绫诲瀷锛宍Type` 閲嶈浇鐩存帴浠 DI 瑙f瀽浼犲叆绫诲瀷銆傚彧鏈夊瓧绗︿覆閲嶈浇鍏堜粠 Page 璺敱瀛楀吀鍙栧緱鏈缁堢被鍨嬶紱鍙栧緱绫诲瀷鍚庝粛閫氳繃鏅 DI 瑙f瀽 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. 绐楀彛锛圖ialog锛夌鐞 +娉涘瀷涓 `Type` 閲嶈浇涓嶈鍙栬矾鐢辫〃銆傚彧鏈夊瓧绗︿覆閲嶈浇浠 Content 璺敱瀛楀吀鍙栧緱鏈缁堢被鍨嬶紝闅忓悗閫氳繃鏅 DI 瑙f瀽瀹炰緥銆傚鑸洰鏍囧彲浠ユ槸 `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` 鏄剧ず闈炴ā鎬佹垨妯℃佺獥鍙o細 + +```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 鑷韩锛坈ode-behind锛夋垨 DataContext锛圴iewModel锛変笂锛屼袱鑰呭彲鍏卞瓨銆 +`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` 瑙f瀽瀵艰埅鐩爣銆傝嫢瀹冧滑鐢辨牴瀹瑰櫒鍒涘缓锛岄偅涔堝湪鍚敤 `ValidateScopes` 鏃讹紝浠庢牴瀹瑰櫒瑙f瀽 scoped 鏈嶅姟鏄棤鏁堢殑锛涗粠鏍瑰鍣ㄨВ鏋愮殑 disposable transient 浼氱敱 Microsoft DI 淇濈暀鍒版牴瀹瑰櫒閲婃斁銆 + +绫诲簱涓嶄細涓烘瘡娆″鑸垱寤烘垨閲婃斁 scope锛屽洜涓哄凡鏄剧ず View 鐨勭敓鍛藉懆鏈熷睘浜庡簲鐢ㄣ傞渶瑕 scoped 鎴 disposable View/ViewModel 瀵硅薄鍥剧殑搴旂敤锛屽繀椤昏嚜琛屾寔鏈夊悎閫傜殑 scope锛屽苟鍒跺畾涓庣晫闈㈡樉绀哄懆鏈熷尮閰嶇殑閲婃斁绛栫暐銆 -- **DI 椹卞姩**锛氭墍鏈 Page 鍜 Window 鍧囩敱 DI 瀹瑰櫒瑙f瀽锛屾敮鎸佹瀯閫犲嚱鏁版敞鍏 -- **WeakReference 绐楀彛缂撳瓨**锛歚DialogManager` 浣跨敤 `WeakReference` 闃叉鍐呭瓨娉勬紡 -- **闄勫姞灞炴ф敞鍐屽尯鍩**锛氶氳繃 `RegionService.RegionName` 鍦 XAML 涓0鏄庡紡娉ㄥ唽瀵艰埅鍖哄煙 -- **绾跨▼瀹夊叏**锛歚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` 鐨勮浆鍙戞柟娉曚繚鐣欙紝鐜版湁浠g爜搴旇縼绉诲埌 `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/21] 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` 鐨勯噸杞戒細娉ㄥ唽璺敱鍒悕銆侾age 涓 Content 鐨 key 绌洪棿鐩镐簰鐙珛銆佸尯鍒嗗ぇ灏忓啓锛屽悓涓绌洪棿鍐呬笉鑳介噸澶嶆敞鍐 key銆 +鍙屾硾鍨嬫棤 key 鐨勯噸杞藉彧娉ㄥ唽 View 鍜 ViewModel锛涘崟娉涘瀷鍔 key 鐨勯噸杞芥敞鍐 View 涓庤矾鐢卞埆鍚嶏紱鍙屾硾鍨嬪姞 key 鐨勯噸杞藉悓鏃舵敞鍐 View銆乂iewModel 涓庤矾鐢卞埆鍚嶃侾age 涓 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/21] 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`锛屾敮鎸佹ā鎬/闈炴ā鎬佺獥鍙c佸弬鏁颁紶閫掍笌缁撴灉杩斿洖銆 +- `DialogService`锛氶氳繃 DI 鍒涘缓骞舵樉绀 `Window`锛涘綋鍓嶅彲闈犺矾寰勬槸闈炴ā鎬佹樉绀恒佸弬鏁颁紶閫掍笌鍏抽棴璇锋眰銆 - `RegionManager`锛氱粺涓绠$悊 XAML 澹版槑鎴栦唬鐮佹敞鍐岀殑鍛藉悕鍖哄煙锛屽苟浠ュ急寮曠敤淇濆瓨鍖哄煙瀹夸富銆 瀵艰埅鐩爣鍏ㄩ儴鐢卞簲鐢ㄧ殑 DI 瀹瑰櫒鍒涘缓銆傜被搴撲笉璁剧疆 `DataContext`锛屽洜姝 View 涓 ViewModel 鐨勬瀯閫犳敞鍏ュ拰缁戝畾鏂瑰紡浠嶇敱搴旂敤鍐冲畾銆 @@ -200,18 +200,15 @@ services.AddTransient(); services.AddTransient(); ``` -浣跨敤 `IDialogService` 鏄剧ず闈炴ā鎬佹垨妯℃佺獥鍙o細 +浣跨敤 `IDialogService` 鏄剧ず闈炴ā鎬佺獥鍙o細 ```csharp var input = new DialogParameters("id", 42); dialogService.Show(input); - -DialogParameters? result = dialogService.ShowDialog(input); -var saved = result?.Get("saved"); ``` -Window 鎴栧畠鐨 `DataContext` 鍙互瀹炵幇 `IDialogAware` 鏉ユ帴鏀跺弬鏁板拰璇锋眰鍏抽棴锛 +瀵逛簬 `Show`锛學indow 鎴栧畠鐨 `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` 瑙f瀽瀵艰埅鐩爣銆傝嫢瀹冧滑鐢辨牴瀹瑰櫒鍒涘缓锛岄偅涔堝湪鍚敤 `ValidateScopes` 鏃讹紝浠庢牴瀹瑰櫒瑙f瀽 scoped 鏈嶅姟鏄棤鏁堢殑锛涗粠鏍瑰鍣ㄨВ鏋愮殑 disposable transient 浼氱敱 Microsoft DI 淇濈暀鍒版牴瀹瑰櫒閲婃斁銆 +`RegisterNavigationService()` 榛樿鎶 `IPageService`銆乣IContentService`銆乣IDialogService` 鍜 `IDialogManager` 娉ㄥ唽涓 singleton銆侾age/Content 瀵艰埅鏈嶅姟涓 `DialogManager` 浼氭崟鑾峰垱寤哄畠浠殑 `IServiceProvider`锛堥氬父鏄牴瀹瑰櫒锛夛紱浠呭垱寤烘垨鎸佹湁涓涓瓙 scope 涓嶄細鎶婅繖浜 singleton 鐨勮В鏋愬垏鎹㈠埌璇 scope銆 + +鍥犳锛屽湪鍚敤 `ValidateScopes` 鏃讹紝浠庢牴瀹瑰櫒瑙f瀽 scoped View銆乂iewModel 鎴 Window 瀵硅薄鍥炬槸鏃犳晥鐨勶紱浠庢牴瀹瑰櫒瑙f瀽鐨 disposable transient 瀵硅薄浼氱敱 Microsoft DI 淇濈暀鍒版牴瀹瑰櫒閲婃斁銆傜被搴撲笉浼氫负瀵艰埅鎴栫獥鍙e垱寤恒佹寔鏈夋垨閲婃斁 scope锛屽洜涓哄凡鏄剧ず UI 鐨勭敓鍛藉懆鏈熷睘浜庡簲鐢ㄣ -绫诲簱涓嶄細涓烘瘡娆″鑸垱寤烘垨閲婃斁 scope锛屽洜涓哄凡鏄剧ず View 鐨勭敓鍛藉懆鏈熷睘浜庡簲鐢ㄣ傞渶瑕 scoped 鎴 disposable View/ViewModel 瀵硅薄鍥剧殑搴旂敤锛屽繀椤昏嚜琛屾寔鏈夊悎閫傜殑 scope锛屽苟鍒跺畾涓庣晫闈㈡樉绀哄懆鏈熷尮閰嶇殑閲婃斁绛栫暐銆 +闇瑕 scoped 鎴 disposable View/ViewModel/Window 瀵硅薄鍥剧殑搴旂敤锛屽繀椤诲湪璋冪敤 `RegisterNavigationService()` 鍓嶈鐩栫浉鍏冲鑸湇鍔″拰绠$悊鍣ㄧ殑鐢熷懡鍛ㄦ湡鎴栬В鏋愮瓥鐣ワ紙鍏 `TryAdd` 娉ㄥ唽浼氫繚鐣欏厛鍓嶆敞鍐岋級锛屼粠搴旂敤鎸佹湁鐨 scope 瑙f瀽杩欎簺鏈嶅姟锛屽苟璁 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/21] 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/21] 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銆侾age/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` 鏃讹紝浠庢牴瀹瑰櫒瑙f瀽 scoped View銆乂iewModel 鎴 Window 瀵硅薄鍥炬槸鏃犳晥鐨勶紱浠庢牴瀹瑰櫒瑙f瀽鐨 disposable transient 瀵硅薄浼氱敱 Microsoft DI 淇濈暀鍒版牴瀹瑰櫒閲婃斁銆傜被搴撲笉浼氫负瀵艰埅鎴栫獥鍙e垱寤恒佹寔鏈夋垨閲婃斁 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/21] =?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 @@ +锘縰sing 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 @@ +锘縰sing 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 @@ +锘縰sing 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 @@ +锘縰sing SimpleNavigation.Common; + +namespace SimpleNavigation.Interface.Awares +{ + /// + /// Window瀵艰埅鍥炶皟鎺ュ彛锛 + /// 鑻ヨ瀹炵幇Window瀵艰埅鍚庢垨鍏抽棴鍚庢墽琛屽洖璋冿紙浼犻掑弬鏁帮級锛孷iew鎴朧iewModel蹇呴』瀹炵幇姝ゆ帴鍙 + /// + public interface IDialogAware : INavigationAware + { + /// + /// 褰揇ialog璇锋眰鍏抽棴鏃惰皟鐢紝鍙紶閫掑弬鏁 + /// + Action? RequestClose { get; set; } + + ///// + ///// 褰揇ialog瀵艰埅瀹屾垚鏃剁殑鍥炶皟鏂规硶 + ///// + ///// 瀵艰埅鍙傛暟 + //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 @@ 锘縰sing SimpleNavigation.Common; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface.Awares { + /// + /// Page瀵艰埅鍥炶皟鎺ュ彛锛 + /// 鑻ヨ瀹炵幇Page瀵艰埅鍚庢墽琛屽洖璋冿紙浼犻掑弬鏁帮級锛孷iew鎴朧iewModel蹇呴』瀹炵幇姝ゆ帴鍙 + /// 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 @@ -锘縰sing SimpleNavigation.Common; - -namespace SimpleNavigation.Interface -{ - public interface IDialogAware - { - /// - /// 褰揇ialog璇锋眰鍏抽棴鏃惰皟鐢 - /// - Action? RequestClose { get; set; } - - /// - /// 褰揇ialog瀵艰埅瀹屾垚鏃剁殑鍥炶皟鏂规硶 - /// - /// 瀵艰埅鍙傛暟 - 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/21] 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 @@ +锘縩amespace SimpleNavigation.Interface.Awares +{ + /// + /// View 鍒濆鍖栧畬姣曞悗锛岃嫢闇瑕佸垵濮嬪寲UI锛屽垯寤鸿缁ф壙璇ユ帴鍙o紝灏哢I鍒濆鍖栭昏緫锛堝瀵艰埅銆佹樉绀哄唴瀹癸級鍐欏埌Initialize鏂规硶涓 + /// + public interface IViewInitializeAware + { + /// + /// 鐢ㄤ簬琛ㄧず鏄惁宸茶繘琛岃繃鍒濆鍖 + /// + bool IsViewInitialized { get; set; } + + /// + /// View 鍒濆鍖栧畬姣曞悗锛岃嫢闇瑕佸垵濮嬪寲UI锛屽垯寤鸿缁ф壙璇ユ帴鍙o紝灏哢I鍒濆鍖栭昏緫锛堝瀵艰埅銆佹樉绀哄唴瀹癸級鍐欏埌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.