NavigationSplitView does not properly show detail pages on macOS 15

The Problem

My team has recently discovered a problem in one of our macOS apps. It uses SwiftUI and Combine, as it still needs to support macOS 13.

It uses a NavigationSplitView to display a sidebar-detail UI.

On macOS 15, we started running into a bug: When selecting an item from the sidebar, the corresponding detail does not initially show up, and a detail page shows up only when selecting a second item from the sidebar. The following message would be printed into Xcode's console:

Publishing changes from within view updates is not allowed, this will cause undefined behavior.

This message would only get printed into the console, and would not be attached to any specific line.

Oftentimes, selecting a subsequent item from the sidebar also either completely clears the detail page, doesn't do anything, and there was a crash reported as well.

The app is compiled using Xcode 16.0.

Architecture Overview

The app uses NavigationSplitView with multiple different detail pages. .id is attached to the detail view in its corresponding NavigationLink, which used to properly show the corresponding detail page.

The ID is stored in an observable AppState, which uses Combine's ObservableObject in the production app, and Observation's Observable in the minimal example.

Example Code

Minimal Reproductible Example

You can see a minimal reproducible example here: https://github.com/buresdv/Navigation-Problems

This example also features a debug field at the end of the sidebar, called "navigationSelection". When clicking one of the items, the ID gets correctly set, but the detail page does not update.

Production App

The production app is a bit more complex, but the core system is the same.

For complete code, see: https://github.com/buresdv/Cork

Some relevant lines include the navigation root, sidebar list, and the navigation links

Notes

  • We were able to reproduce these issues on multiple user systems
  • This issue seems to have started occuring when we updated to macOS 15
    • At the same time, we updated the app to Strict Concurrency Checking and Swift 6, although we did not change anything in the navigation system
Answered by Frameworks Engineer in 808776022

This was release noted here: https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes (Search 127626852)

Behavior was changed because the composition is undefined on iOS and was undefined on macOS, but happened to work in some cases.

This change was made behind a link check, so hopefully you are only seeing this when compiling against the newest Sequoia toolchains.

View-destination links (e.g. NavigationLinks that take a View as their destination) and value-destination links (e.g. NavigationLinks that take a value argument) cannot be mixed.

By attaching an id to the link, you are wiring the link up to the Lists selection, and thus trying to drive navigation that way. The correct way to do this in releases including and prior to iOS 15 and macOS 18 is by switching over the selection in the NavigationSplitView's subsequent column:

NavigationSplitView {
  List(selection: $selection) {
    NavigationLink("Value-destination link", value: 5)
  }
} detail: {
  if let selection {
    SelectedDestination(selection)
  } else {
    ContentUnavailableView(...)
}
Accepted Answer

This was release noted here: https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes (Search 127626852)

Behavior was changed because the composition is undefined on iOS and was undefined on macOS, but happened to work in some cases.

This change was made behind a link check, so hopefully you are only seeing this when compiling against the newest Sequoia toolchains.

View-destination links (e.g. NavigationLinks that take a View as their destination) and value-destination links (e.g. NavigationLinks that take a value argument) cannot be mixed.

By attaching an id to the link, you are wiring the link up to the Lists selection, and thus trying to drive navigation that way. The correct way to do this in releases including and prior to iOS 15 and macOS 18 is by switching over the selection in the NavigationSplitView's subsequent column:

NavigationSplitView {
  List(selection: $selection) {
    NavigationLink("Value-destination link", value: 5)
  }
} detail: {
  if let selection {
    SelectedDestination(selection)
  } else {
    ContentUnavailableView(...)
}

Thanks a lot for the explanation! I didn't see it in the release notes.

I have a related question: Before I start trying to hack something together, is there a recommended way to do this type of value navigation if you can select two completely unrelated types from the list, which in turn navigate to unrelated detail views?

In Cork, these are:

  • PackageDetailView, which takes the BrewPackage type
  • TapDetailView, which takes the BrewTap type

Until now, this was solved by using the incorrect behavior of defining the destination view inside the NavigationLink:

NavigationLink
{
    PackageDetailView(package: package)
        .id(package.id)
} label: {
    PackageListItem(packageItem: package)
}
NavigationLink
{
     TapDetailView(tap: tap)
         .id(tap.id)
} label: {
     Text(tap.name)
}

In this example, and in other examples I could find, only one type is used.

I ended up implementing a solution where I wrap the destinations in a hashable enum like this:

enum NavigationTargetMainWindow: Hashable
{
    case package(BrewPackage)
    case tap(BrewTap)
}

And then navigate to them using the value like this:

NavigationLink(value: NavigationTargetMainWindow.tap(tap))
{
    Text(tap.name)
    
    if tap.isBeingModified
    {
        Text(tap.name)
    }
}

This still didn't work properly, so I had to move the navigation selection variable out of my global AppState and into a local @State variable; only then I stopped getting the message "Publishing changes from within view updates is not allowed, this will cause undefined behavior."

Another Issue Again

Now I'm however running into another issue: I have a .task on the detail view that's supposed to load additional data. This .task only gets triggered on the first load of the detail view, and never again, so the detail retains the stale data. I suppose SwiftUI is trying to be clever and not reconstruct the entire view?

I tried using .task(id: package.id), and this does fire the task, but there's a split second where the old view is still visible, which isn't desirable:

https://streamable.com/3ucoy9

NavigationSplitView does not properly show detail pages on macOS 15
 
 
Q