Skip to content

feat(routing)!: report navigation through plain observables of values - #4497

Merged
glennawatson merged 2 commits into
mainfrom
feat/routing-navigation-events
Sep 26, 2026
Merged

glennawatson merged 2 commits into
mainfrom
feat/routing-navigation-events

Conversation

@glennawatson

Copy link
Copy Markdown
Contributor

Summary

RoutingState reports navigation through plain IObservable<T> streams of values, and routing no longer depends on DynamicData.

  • NavigationStackChanged replaces NavigationChanges. It emits a read-only snapshot of the whole navigation stack after each change, oldest view model first, instead of a change set.
  • CanNavigateBack is new. It emits whether the stack holds a view model to go back to, when you subscribe and whenever the answer changes. NavigateBack uses it to decide when it can execute.
  • CurrentViewModel is typed IObservable<IRoutableViewModel?>. It already emitted null while the stack was empty; the type now says so.
  • Every routing consumer follows the new streams. WhenNavigatedTo, WhenNavigatedToObservable, WhenNavigatingFromObservable, the MAUI RoutedViewHost stack sync and the UIKit RoutedViewHost no longer use change sets or CollectionChanged events.
  • The ReactiveUI.Routing and ReactiveUI.Routing.Reactive packages are removed, with their tests, the change-set count benchmark and the DynamicData package version.
  • The routing handbook examples and the README routing paragraph describe the new streams.

Why

Routing exposed a generic change-set type that consumers had to decode, and the DynamicData interop lived in separate packages that pulled System.Reactive back in.

Closes #4478

Breaking changes

Routing members and packages changed; each has a direct replacement.

  • RoutingState.NavigationChanges is removed. Subscribe to RoutingState.NavigationStackChanged, which emits after each change only; read NavigationStack for the stack as it is when you subscribe.
  • RoutingState.CurrentViewModel is now IObservable<IRoutableViewModel?>. Handle the null it emits for an empty stack.
  • Code that counted stack changes to drive a back button can subscribe to RoutingState.CanNavigateBack.
  • The ReactiveUI.Routing and ReactiveUI.Routing.Reactive packages are gone. NavigationChanged() becomes NavigationStackChanged; CountChanged() and HasCountChanged() become WhenCountChanged() and CountHasChanged() on ToReactiveChangeSet(); ActOnEveryObject on a DynamicData change set becomes ActOnEveryObject on the collection; ToDynamicDataChangeSet() has no replacement, so call DynamicData's ToObservableChangeSet() in your own code.

How this was verified

New tests cover every new and changed RoutingState member, the focus mixins' seeding, error and completion paths, and the MAUI stack resync on clear, in both the lean and System.Reactive flavours; a lean console app subscribes to a signal with a lambda and to all three routing streams without loading System.Reactive or DynamicData.

  • The UIKit RoutedViewHost compiles only for Apple targets and has no automated test here.

Notes for the reviewer

Start with RoutingState and RoutableViewModelMixins; the project, package and benchmark removals are mechanical.

  • CurrentViewModel and CanNavigateBack derive from the NavigationStackChanged property at subscribe time, so a subclass that replaces that stream drives all three.
  • The focus mixins keep their previous rules: they react to changes in stack size, and they complete when the watched view model leaves the stack. With a snapshot, a view model that appears twice completes only when its last copy leaves.
  • The UIKit host now tells a push from a reset by comparing stack sizes: a larger stack pushes the view for the new top, and an empty stack pops to the root.
  • Core's general collection change-set APIs are unchanged.

Checklist

  • I have read the Contribute guide
  • The PR title follows Conventional Commits
  • Tests cover this change, or the summary says why they do not
  • New or changed public API has XML documentation

RoutingState now reports navigation through IObservable<T> streams of
domain values instead of a change-set stream, and routing no longer
depends on DynamicData in either the lean or the System.Reactive flavour.

- NavigationStackChanged emits a read-only snapshot of the whole stack
  after each change, oldest view model first.
- CurrentViewModel emits the view model on top of the stack on subscribe
  and after each change, and is typed as nullable because it emits null
  while the stack is empty.
- CanNavigateBack emits whether the stack holds a view model to go back
  to, on subscribe and whenever the answer changes. NavigateBack uses it
  as its can-execute stream.
- The focus mixins (WhenNavigatedTo, WhenNavigatedToObservable,
  WhenNavigatingFromObservable), the MAUI RoutedViewHost stack sync and
  the UIKit RoutedViewHost now follow NavigationStackChanged.
- The ReactiveUI.Routing and ReactiveUI.Routing.Reactive projects, their
  tests, the change-set count benchmark and the DynamicData package
  version are removed.

BREAKING CHANGE: routing exposes plain observables of values and the
DynamicData routing packages are gone.
- RoutingState.NavigationChanges (IObservable<IReactiveChangeSet<IRoutableViewModel>>)
  -> RoutingState.NavigationStackChanged (IObservable<IReadOnlyList<IRoutableViewModel>>).
  It emits after each change only; read RoutingState.NavigationStack for
  the stack as it is when you subscribe.
- RoutingState.CurrentViewModel: IObservable<IRoutableViewModel>
  -> IObservable<IRoutableViewModel?>. The values are unchanged.
- Counting stack changes to enable a back button
  -> RoutingState.CanNavigateBack (IObservable<bool>).
- ReactiveUI.Routing / ReactiveUI.Routing.Reactive packages -> removed;
  drop the package reference.
- RoutingStateDynamicDataMixins.NavigationChanged()
  -> RoutingState.NavigationStackChanged.
- DynamicDataChangeSetMixins.CountChanged() / HasCountChanged()
  -> ChangeSetMixins.WhenCountChanged() / CountHasChanged() on
  IReactiveChangeSet<T> from ToReactiveChangeSet().
- DynamicDataInteropMixins.ToDynamicDataChangeSet()
  -> removed; call DynamicData's ToObservableChangeSet() on the
  collection in your own code.
- DynamicDataAutoPersistMixins.ActOnEveryObject() on IChangeSet<T>
  -> AutoPersistHelperMixins.ActOnEveryObject() on the collection or on
  an IReactiveChangeSet<T> stream.

Closes #4478
@codecov

codecov Bot commented Sep 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.63%. Comparing base (76926d8) to head (ebf4646).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4497      +/-   ##
==========================================
+ Coverage   93.42%   93.63%   +0.20%     
==========================================
  Files         168      163       -5     
  Lines        6346     6268      -78     
  Branches      733      728       -5     
==========================================
- Hits         5929     5869      -60     
+ Misses        416      398      -18     
  Partials        1        1              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sonarqubecloud

Copy link
Copy Markdown

@glennawatson
glennawatson merged commit 2a59e1d into main Sep 26, 2026
13 of 15 checks passed
@glennawatson
glennawatson deleted the feat/routing-navigation-events branch September 26, 2026 05:03
glennawatson added a commit that referenced this pull request Sep 26, 2026
## Summary

**Runnable documentation examples now cover the public API of every lean
ReactiveUI package, one page project per handbook page.**

- **New page projects under `src/examples/Documentation/Pages`.** Each
project matches one page of the handbook on reactiveui.net (view models,
commands, bindings, activation, interactions, view location, routing,
collections, message bus, scheduling, persistence, testing, the app
builder, registration, reflection) and runs as a console app that prints
what each example does.
- **Platform page projects for WPF, WinForms, WinUI, Blend and Drawing,
MAUI, Blazor and Android.** Each is a small realistic app (a school
timetable, a weather station dashboard, a course list) that runs its
scenario end to end and prints the result; the Windows apps expose
`--smoke`.
- **Every example states its output.** Each example method ends in an
`// Output:` block, and the website quotes the examples verbatim.
- **Examples follow the merged fixes.** Routing uses the #4497
navigation streams, WinUI shows the #4496 AOT-safe hosts with their
`*Unsafe` twins as the explicit opt-in, and the builder, MAUI host,
ReactiveRecord, suspension and Android orientation examples show the
fixed behaviour.
- **CI tests only the projects under `tests/`.** The solution now builds
the example apps, and `dotnet test` over the whole solution tried to run
them, including the Android and MAUI apps, which need a device.
- **The examples build with the same rules as Refit's.**
`src/examples/Documentation` carries Refit's `.editorconfig` (explicit
types instead of `var`), and `src/examples/Directory.Build.props` marks
the examples as not packable.

## Why

**The handbook needs an example for every public member, and each
example has to compile and run against the current code.**

- The pages on reactiveui.net quote these projects, so a page cannot
drift from the API without a build or output check failing.
- Members that no example calls are reviewed one by one: the platform or
the framework calls them, another ReactiveUI package uses them, or they
need hardware the examples cannot run on.

## Breaking changes

**None.** The examples are not packaged.

## How this was verified

**Every page project builds with no warnings, and each project's printed
output matches its `// Output:` blocks.** The Windows apps ran their
`--smoke` scenario on Windows, and the Android app ran its full scenario
on an emulator. iOS and Mac Catalyst were not run.

## Notes for the reviewer

**The code is sample code, so review it for readability and realism, not
for architecture.**

- Start with any one page project's `Program.cs`, which lists that
page's examples in reading order.
- `src/examples/Documentation/Directory.Build.props`,
`src/examples/Directory.Build.props`, `.editorconfig` and the
`reactiveui.slnx` entries are the only build changes.
- Overload families such as `WithInstance<T1..T16>` are shown at one and
two arguments only; the pages state how many the family supports.
- The WinUI maintenance panel uses `RoutedViewHostUnsafe` and
`ViewModelViewHostUnsafe` for a view added with `Map`, because the
default hosts cannot find a mapped view until #4502 is fixed.

## Checklist
- [x] I have read the [Contribute
guide](https://www.reactiveui.net/contribute/index.html)
- [x] The PR title follows [Conventional
Commits](https://www.conventionalcommits.org/)
- [x] Tests cover this change, or the summary says why they do not
- [ ] New or changed public API has XML documentation
@github-actions

Copy link
Copy Markdown

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Oct 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adding ReactiveUI.Routing to a lean ReactiveUI app makes every observable.Subscribe(Action<T>) fail with CS0121

1 participant

⌂ 换网址