Skip to content

Introduce Navigating protocol to make decision handlers testable#246

Merged
svara merged 8 commits into
mainfrom
navigator-protocol
Jul 2, 2026
Merged

Introduce Navigating protocol to make decision handlers testable#246
svara merged 8 commits into
mainfrom
navigator-protocol

Conversation

@svara

@svara svara commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Why

Route and web view policy decision handlers receive a concrete Navigator, so unit-testing a custom handler means subclassing Navigator and dragging in real Session and WKWebView instances. Handlers actually touch only a thin slice of Navigator (route, reload, activeNavigationController).

This introduces a Navigating protocol so handlers depend on an abstraction and can be tested against a lightweight double.

What

  • New Navigating protocol that refines the existing NavigationHandler, exposing the navigation surface handlers actually use: the route overloads, pop/clearAll/reload, the root/modal/active navigation controllers, and a new currentVisitableURL convenience (default implementation reads the active navigation controller's top visitable).
  • Navigator conforms with no additional code.
  • RouteDecisionHandler / WebViewPolicyDecisionHandler (and the Router / WebViewPolicyManager that drive them) now take Navigating instead of the concrete Navigator.
  • NavigationSpy is now a lightweight standalone Navigating conformer (no Session/WKWebView), and the decision handler tests use it instead of a real Navigator.

Design notes

  • Surface kept lean. session/modalSession, webkitUIDelegate, delegate, and start() are intentionally not on the protocol — they're implementation details and would force test doubles to fabricate heavyweight collaborators.
  • All route overloads live on NavigationHandler. The full-control route(_:options:parameters:) is the requirement; route(_:) is a default implementation in terms of it, so conformers (incl. HotwireTabBarController) implement only the primitives.

Breaking changes (source)

Both are minor and apply only to code that implements these protocols, not code that calls them:

  1. Custom RouteDecisionHandler / WebViewPolicyDecisionHandler implementations must change their handle(...) navigator parameter from Navigator to Navigating (one-line signature change; bodies unchanged). This is the intended enabler for testing them.
  2. Adding route(_:options:parameters:) as a NavigationHandler requirement affects any external type conforming to NavigationHandler directly (a consume-not-implement protocol; no known external conformers).

Testing

The routing handler suites also got noticeably faster now that they no longer spin up real sessions/web views.

svara added 7 commits June 30, 2026 12:30
Add a public `Navigating` protocol that abstracts the navigation surface
that route and web view policy decision handlers rely on, so handlers can
be exercised against a lightweight test double instead of a concrete
`Navigator`.

It refines the existing `NavigationHandler` (route-only) with
pop/clearAll/reload, the root/modal/active navigation controllers, the
active web view, and a `currentVisitableURL` convenience whose default
implementation reads the active navigation controller's top visitable.
`Navigator` already implements every member, so the conformance is empty.
`currentVisitableURL` is satisfied by the protocol's default implementation.
Change the `navigator` parameter from the concrete `Navigator` to the
`Navigating` protocol in `RouteDecisionHandler` and
`WebViewPolicyDecisionHandler`, the `Router`/`WebViewPolicyManager` that
drive them, and all of the built-in handlers. Handler bodies are
unchanged -- they only use members the protocol exposes.
Rewrite `NavigationSpy` as a standalone `Navigating` conformer that
records route/reload calls and exposes a settable `currentVisitableURL`,
instead of subclassing `Navigator` and creating real `Session` and
`WKWebView` instances. Update the in-test decision handler spies to the
new `Navigating` signature.
The router, web view policy manager, and individual route decision
handler tests only pass the navigator through to the code under test;
they never rely on real navigation. Swap the concrete `Navigator` for
the lightweight `NavigationSpy`, which avoids creating real `Session`
and `WKWebView` instances.

The Navigator-behavior tests (NavigationHierarchyController*,
NavigationDelegateTests) keep the real `Navigator`, since they exercise
its actual navigation behavior.
Neither the built-in decision handlers nor the bc3-ios/hey-ios route and
web view policy handlers ever read the navigator's web view; the only
navigator-level web view/session access in those apps lives in
coordinators that hold a concrete `Navigator`. Keeping `activeWebView` on
the protocol forced every conformer (and test double) to fabricate a
`WKWebView` for no benefit, so remove it. `Navigator` keeps its own
public `activeWebView` for concrete callers.
`NavigationHandler` is the "trigger a visit" contract, so all of the
`route` overloads belong together there. Move the full-control
`route(_:options:parameters:)` down from `Navigating`, and provide
`route(_:)` as a default implementation in terms of it. Conformers now
only implement `route(_:options:parameters:)` and `route(_ proposal:)`:
`Navigator` satisfies both with its own methods (empty conformance), and
`HotwireTabBarController` forwards the full-control overload to its
active navigator and drops its hand-written `route(_:)`.

`Navigating` still exposes the overloads through protocol inheritance, so
nothing changes for decision handlers.
@svara
svara marked this pull request as ready for review June 30, 2026 11:26
@svara
svara requested review from joemasilotti and zoejessica June 30, 2026 11:26

@zoejessica zoejessica left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely done @svara, I'm really happy to see this one get merged in! 👏🚀

* main: (21 commits)
  Restore navigation simulator leak fix lost in merge
  Test unsafe-scheme handling.
  Cancel external navigation to unsafe URL schemes
  Handle taps inside iframes that request a new window
  Revert "Handle requests for new windows by proposing a new visit"
  Add tests for always-on visit retry handler
  Fix inaccurate log messages in Navigator session inspection
  Log HTTP status code on cold boot visit failures
  Add logging to JSFetch recovery handler and error paths
  Retry failed visit directly when no topmost visitable
  Remove isRetryable logic and always provide clients with a retry handler
  Update tests
  Rename to JSFetchRecoveryHandler
  Update to allow retries for offline and timeout errors
  Additional logging
  Lower logging level for WebViewPolicyManager
  Allow injection of a custom logger
  Handle requests for new windows by proposing a new visit
  Place menu popover on the element that triggered it
  Use latest method on iOS 16+; refactor tests
  ...
@svara
svara merged commit 3dc7f03 into main Jul 2, 2026
1 check passed
@svara
svara deleted the navigator-protocol branch July 2, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants