Skip to content

Fix notification/deep-link URL being discarded on cold start (#4145)#4740

Draft
dimdal wants to merge 5 commits into
home-assistant:mainfrom
dimdal:fix-4145-cold-start-notification-url
Draft

Fix notification/deep-link URL being discarded on cold start (#4145)#4740
dimdal wants to merge 5 commits into
home-assistant:mainfrom
dimdal:fix-4145-cold-start-notification-url

Conversation

@dimdal

@dimdal dimdal commented Jun 14, 2026

Copy link
Copy Markdown

Summary

Fixes #4145. Tapping a notification with a url (or a URI action targeting the Home Assistant frontend) navigates correctly when the app is already running, but on a cold start the URL is discarded and the app just opens to its default page.

Root cause — a cold-start race. On cold start the webview starts blank (webView.url == nil). The notification tap drives open(inline:)load(notificationURL), but loadActiveURLIfNeeded() — fired independently from viewWillAppear and HomeAssistantAPI.didConnectNotification — loads the default server URL whenever webView.url == nil. Whichever load() lands last wins, and it is usually the default, so the notification URL is lost. When the app is already running webView.url is non-nil, loadActiveURLIfNeeded() early-returns, and open(inline:) always wins — which matches the reports (works when open, and one reporter seeing it work intermittently is the race surfacing).

Fix. Mirror the existing initialURL restoration mechanism with a pendingOpenInlineURL:

  • open(inline:) records the explicitly requested URL before loading it.
  • loadActiveURLIfNeeded() gives that pending URL priority over the restored/default URL (new pure helper prioritizedInlineURL(...)).
  • didFinish clears it once the navigation lands.

Because the pending URL is re-asserted while the webview is still blank and only cleared on a successful navigation, it wins in every ordering of the two loads, so the URL is no longer discarded. Deep links benefit too, since they share the same open(inline:) path.

Scope: this covers URLs that resolve to the HA frontend (server URLs and relative paths such as /frigate/review/...), which is the path in the issue. The separate action: URI → external browser path (openURLInBrowser) is not touched here.

Added unit tests in WebViewControllerPendingURLTests (registered in the Tests-App target so CI compiles and runs them) covering the priority helper, open(inline:) recording the pending URL, the cold-start race itself, and pending-URL clearing on navigation failure (kept on cancellation).

Screenshots

No UI change — this is a navigation/timing fix, so there is nothing visual to capture. Behaviour: with the app force-closed, tapping a notification carrying a url now lands on that URL instead of the default dashboard.

Link to pull request in Documentation repository

Documentation: home-assistant/companion.home-assistant#

Any other notes

🤖 Generated with Claude Code

dimdal and others added 4 commits June 13, 2026 15:16
…t#4145)

On cold start webview starts blank, so loadActiveURLIfNeeded() (fired
from viewWillAppear + HA-connect notification) races open(inline:) and
loads the default server URL instead of the notification's URL.

Track the explicitly requested URL (pendingOpenInlineURL); give it
priority in loadActiveURLIfNeeded over restored/default; clear on
didFinish. Wins every load ordering, so URL no longer discarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lank

- Clear pendingOpenInlineURL on non-cancelled didFail/didFailProvisional
  so a permanently-failing URL doesn't get re-forced on later active-URL
  loads. Cancelled failures keep it (a superseding load must not lose it).
- didFinish: don't clear on about:blank (loaded by showNoActiveURLError),
  which would drop the pending URL before the real navigation runs.
- Note baseIsEqual same-server semantics on prioritizedInlineURL.
- Add tests for real-failure clear and cancelled-keeps-pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… too

Move the pendingOpenInlineURL clear out of the URLError cast in didFail so
any non-cancelled committed-navigation failure clears it, not just URLError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…LRequest

The earlier tests lived in WebViewControllerTests.swift, which has no
project/target reference (orphaned upstream) — CI never compiled or ran
them. Move them to WebViewControllerPendingURLTests.swift, registered in
Tests-App, so CI runs them (7 tests, incl. the cold-start race regression).

Extract activeURLRequest(for:) from loadActiveURLIfNeeded so the closure
stays under the cyclomatic_complexity limit. No behavior change.

Verified locally on Xcode 26.3 / iOS 26.3 sim: 7/7 pass; disabling the
fix branch fails only the cold-start integration test (red→green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 14, 2026 07:13

@home-assistant home-assistant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hi @dimdal

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

@home-assistant home-assistant Bot marked this pull request as draft June 14, 2026 07:13
@home-assistant

Copy link
Copy Markdown

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Copilot AI 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.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR addresses a cold-start race where loadActiveURLIfNeeded() could overwrite an explicit notification/deep-link navigation by introducing a “pending inline URL” concept and prioritizing it during initial loads.

Changes:

  • Add pendingOpenInlineURL to persist an explicit open(inline:) request across cold-start loads.
  • Refactor loadActiveURLIfNeeded() URL selection into activeURLRequest(for:) with prioritization logic.
  • Add a dedicated XCTest suite validating prioritization and failure/clearing behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
Tests/App/WebView/WebViewControllerPendingURLTests.swift Adds tests for pending inline URL prioritization and clearing behavior.
Sources/App/Frontend/WebView/WebViewController.swift Introduces pendingOpenInlineURL state with rationale.
Sources/App/Frontend/WebView/WebViewController+WebKitDelegates.swift Clears/retains pending URL based on navigation outcomes and special-cases about:blank.
Sources/App/Frontend/WebView/WebViewController+URLLoading.swift Centralizes active-URL request selection and adds prioritizedInlineURL.
Sources/App/Frontend/WebView/WebViewController+Navigation.swift Records the pending URL when open(inline:) is invoked.
HomeAssistant.xcodeproj/project.pbxproj Registers the new test file in the test target.

Comment on lines 39 to +47
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
refreshControl.endRefreshing()

// A committed navigation truly failed; stop forcing the requested URL so the default can take
// over. Only on a *real* failure — a `.cancelled` error just means a newer load superseded an
// in-flight one, and clearing then would revive the cold-start race (#4145).
if !error.isCancelled {
pendingOpenInlineURL = nil
}
Comment on lines +101 to +106
// the explicit navigation has landed; stop forcing it on subsequent active-URL loads (#4145).
// skip about:blank, which `showNoActiveURLError()` loads — clearing then would drop the
// pending URL before the real navigation gets a chance to run.
if webView.url?.absoluteString != "about:blank" {
pendingOpenInlineURL = nil
}
Comment on lines +109 to +122
if let currentURL = webView.url, currentURL.path.count > 1 {
// Preserve the current path when the base URL changes (e.g., switching between internal/external)
var components = URLComponents(url: webviewURL, resolvingAgainstBaseURL: true)
components?.path = currentURL.path
if let query = currentURL.query {
// Preserve external_auth if present, add other query items
var queryItems = components?.queryItems ?? []
let currentQueryItems = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)?
.queryItems ?? []
for item in currentQueryItems where item.name != "external_auth" {
queryItems.append(item)
}
components?.queryItems = queryItems
}
Comment on lines +104 to +106
if webView.url?.absoluteString != "about:blank" {
pendingOpenInlineURL = nil
}
Comment on lines +78 to +81
let wasCatalyst = Current.isCatalyst
// Take the synchronous load path (skips the async connectivity sync used on iOS).
Current.isCatalyst = true
defer { Current.isCatalyst = wasCatalyst }
Comment on lines +36 to +41
/// A URL requested explicitly via `open(inline:)` (notification tap or deep link) that must take
/// priority over the automatic active-URL load. On cold start the webview begins blank, so
/// `loadActiveURLIfNeeded()` — fired from `viewWillAppear` and the HA-connect notification — would
/// otherwise race with and overwrite this navigation with the default server URL, discarding the
/// requested URL (#4145). Cleared once a navigation finishes (`didFinish`).
var pendingOpenInlineURL: URL?
@bgoncal

bgoncal commented Jun 14, 2026

Copy link
Copy Markdown
Member

Please provide a screen recording showing before and after

@codecov

codecov Bot commented Jun 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@3c62221). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4740   +/-   ##
=======================================
  Coverage        ?   45.54%           
=======================================
  Files           ?      275           
  Lines           ?    16606           
  Branches        ?        0           
=======================================
  Hits            ?     7563           
  Misses          ?     9043           
  Partials        ?        0           

☔ 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.

…d binding

- Expand the pendingOpenInlineURL doc comment to state the real clearing
  conditions (didFinish except about:blank; genuine failures; kept on
  .cancelled).
- Replace the unused 'if let query' binding in activeURLRequest with a
  nil-check (behavior unchanged; silences the unused-value warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Home Assistant Companion App ignores url parameter

3 participants