visionOS 26: is there any way yet to distinguish a user-initiated window close from system out-of-FoV backgrounding

This was confirmed as a framework gap in an accepted answer from an Apple Vision Pro engineer in June 2024 (https://developer.apple.com/forums/thread/758014?answerId=792769022#792769022): .background fires identically whether the user taps a window's close button or the system backgrounds a window that's been out of the field of view for ~61 seconds, and there's no app-visible signal that distinguishes the two. The recommendation at the time was to use a gesture/affordance to reopen the window, and to file an enhancement request.

Two years on, with the window-management APIs that have shipped since, I want to confirm whether the situation has changed as of visionOS 26.

My case: VisionBlazer, a native spatial 3D creation tool (TestFlight beta, August 2026 launch). Users routinely work with several SwiftUI WindowGroup tool windows open at once — drawing tools, materials, timeline, properties, lighting — parked spatially around an ImmersiveSpace. Parking a window behind or beside you is core to the workflow. The app should terminate when the user closes the primary window, but must not terminate when a secondary window (or the primary) is simply parked out of view.

What I've verified on-device (visionOS 26.5):

scenePhase == .background fires identically for a user close tap and for the ~61s out-of-FoV backgrounding (SurfBoard: "…is out of FOV after 60.99 seconds. Backgrounding"). The phase sequence (active → inactive → background) and timing (~0.2–0.4s gap) are indistinguishable between the two cases. scenePhase on visionOS reflects visibility, not focus — a parked window stays .active while the user edits elsewhere, until the out-of-FoV timer fires. The session identifier reachable from the window's view hierarchy (view.window!.windowScene.session.persistentIdentifier) never matches the identifier reported by application(_:didDiscardSceneSessions:) or UIScene.didDisconnectNotification for that same close. The view-visible session stays in UIApplication.shared.openSessions indefinitely after the close. Those disconnect/discard callbacks arrive ~10–15s late and only ever carry foreign session identifiers, so they can't be attributed to a specific window. Stale-session discards from prior launches pollute the signal further. onDisappear does not fire on user close.

Five strategies tried, all failed: (1) scene-object identity captured at didMoveToWindow; (2) session.persistentIdentifier matching against openSessions; (3) live re-capture of scene/session from the view hierarchy on every lifecycle change; (4) temporal correlation of didEnterBackground/didDisconnect; (5) a focus-recency heuristic on scenePhase transitions. All fail on the identifier mismatch and the visibility-not-focus semantics above.

Questions:

As of visionOS 26, is there now any supported way to detect that the user intentionally closed a specific window, distinct from system backgrounding? Is there a SwiftUI or scene-delegate callback tied to a window's own scene that fires only on user close? Is there a dismissalReason (or equivalent) anywhere on the close path? If none of the above exists, is an explicit in-app Quit button still the intended pattern for "quit when the main window is closed"?

I have a focused test project reproducing all of this and can link it. Thanks.

Answered by Vision Pro Engineer in 900051022

Hey @RichBlum,

Thanks so much for the update. It was great to hear that I was able to point you in the right direction. Super excited to see these learnings implemented. I also appreciate you sharing your additional findings.

  • You are experiencing the correct behavior of defaultLaunchBehavior(.presented). You highlight a need for a new API for a behavior on app activation when the app is already running. Please file an enhancement request using Feedback Assistant for this request.
  • Thanks for this callout, we'll consider options on how we can better communicate this with developers.
  • Please do file a feedback with a repro. Feedback Requests are always looked at and valuable to all teams at Apple. Additionally, if you could post the FB number here when you do, I'd love to take a look at it as well.
  • Please avoid relying on undocumented behavior. Please file an enhancement request if there's an API you'd like to see implemented. With additional information I may be able to recommend alternative approaches.

Thanks,
Michael

Hey @RichBlum,

There is no API to determine what triggered a scene change to the background. Please file an enhancement request using Feedback Assistant with details as to why and how you'd like to use an API for this feature.

I'd love to learn more about your use case and understand if I can point you to a current API that might be able to serve you better. I'd certainly love to look at your test project reproducing the issue.

The app should terminate when the user closes the primary window, but must not terminate when a secondary window (or the primary) is simply parked out of view.

This sentence concerns me as it's up to the user to close the last scene of your app. Your app can dismiss its own scenes as long as it’s not the last scene. I'd love to see an example of the complete flow of your app from launching the app, to leaving the experience to better understand what APIs you should be using.

I would encourage you to checkout how this is handled in the following samples that use multiple scenes:

Thanks,
Michael

Thanks Michael — enhancement request filed as FB24076974, and a focused test project is here: https://www.icloud.com/iclouddrive/0fcptDNbtKk0CCcs-EYMS_2bA#WindowCloseRepro Building it produced results worth reporting, including a correction to my original post.

Complete flow: Launch opens the primary window (the editor menu) which immediately opens a fully immersive space — the space IS the product: users build and animate 3D scenes inside it in .full immersion. They open and close secondary tool windows (materials, timeline, lighting, …) around the space as they work, and park any window — including the primary — beside or behind them. There is also a player flow: tapping a scene file in Files launches straight into the immersive space with a single compact transport-bar window.

Correction first: in the focused repro (visionOS 26.5, on-device), the session-identifier mismatch I reported does NOT reproduce. While another regular window is open, closing a window delivers didDisconnect + didDiscardSceneSessions within ~30 ms, carrying an identifier that matches the closed window's view-reachable windowScene.session.persistentIdentifier, plus root-view onDisappear — attribution works, exactly as your window-life-cycle article describes. My earlier in-app observations of unmatched identifiers were evidently stale-session discards misattributed to the close. I'm happy to be wrong about that half.

The gap that DOES reproduce, cleanly: closing the LAST regular window while the fully immersive space remains open. On-device, that close produces didEnterBackground + scenePhase → background and then nothing — no disconnect, no discard, no onDisappear, session still in openSessions — for the entire time the user remains in the immersive space. That signature is identical to the ~61 s out-of-view backgrounding, so the app cannot distinguish "the user closed my last window and is now standing inside my environment with no chrome at all" from "my window is parked behind the user". Your doc states this case directly ("The last closed nonimmersive scene enters the ScenePhase.background phase but doesn't immediately receive the onDisappear() callback") — for a fully-immersive app it is the one lifecycle moment we most need to observe (dismiss the space, save state, end the session), and the one close that produces no signal.

On your concern — the app never dismisses its own last scene. The problem is the reverse direction: after the USER closes the last window, they're still inside our fully immersive environment, which has no close affordance of its own. From the user's perspective that close should end the session; the app just has nothing to act on.

The enhancement filed: deliver onDisappear/disconnect (or a dismissal callback with a reason) to the last nonimmersive scene when an immersive space remains open — or extend the association model from your "Associating a window with an immersive space" sample (which pairs a pushed window with a space) to a primary window.

One question meanwhile: is there a recommended pattern today for a fully-immersive app whose last window closes — is "Embedding controls in an immersive space" (persistent in-space chrome) the intended fallback?

Hey @RichBlum,

Thanks for this additional information.

I want to directly address your comment from FB24076974 which I feel summarizes your request.

For an app whose fully immersive space outlives its windows, the close of its last regular window is the one lifecycle moment it most needs to observe — the user is now standing inside the app's environment with no chrome at all, and the app should be able to respond (dismiss the space, save state, or end the session). It is the one close that produces no signal.

The app can respond to this state and we showcase this behavior in our sample code. The app cannot dismiss the immersive space when it is the app's only open scene – it is up to the user to close the application. It seems like you want to programmatically quit your app (from a custom button or based off scene state), but this is unsupported. On iOS, swiping closes an app; on visionOS there is the system provided close button or the Digital Crown.

Hello World does a great job of showcasing the expected behavior for three different types of experiences:

  • the Planet Earth module shows the behavior with a volume.
  • the Objects in Orbit module shows the behavior of an immersive space that doesn't require a window. In this immersive space a ViewAttachmentComponent is placed in front of the user when the main window is closed. The user has the option to leave the immersive space or reopen the main window. Depending on your experience you could reopen the main window by tapping on an entity in your scene or attach this component to another entity rather than placing it statically in the space.
  • the Solar System module presents a new window that only exists within the Immersive Space. That window and the immersive space always close together. Use this behavior for when these two scenes should not be displayed independently from each other.

If the user might want to be in the immersive space without a window from another scene, follow the behavior outlined in Embedding controls in an immersive space. In this case the user can press the Digital Crown to close the app (and the immersive space) at any time. When the user relaunches the app from the home screen the system opens the preferred scene of the default scene type; this can be configured by the defaultLaunchBehavior method.

If the immersive space should exist alongside another window, follow the example set in Associating a window with an immersive space.

The example project that you included provides a great illustration of the issue from a API standpoint, but I'd like to get to know the flow of your application better.

  • Why does launching the primary window immediately open an immersive space?
  • Is there a case where someone using your app might want to navigate your app side-by-side with other applications?
  • Why is there a difference when launching from a scene file which doesn't provide the primary editor menu window?
  • Have you considered an experience that starts with a window previewing the different immersive content? From that window the user could choose to open the immersive space with the editor menu or the compact menu.

Thanks,
Michael

Michael, thanks much for your help which led to resolving our issues. We ran Hello World on-device, studied both samples, and rebuilt our app's window lifecycle around them. Everything you suggested is now implemented:

Adopted:

  • A window-first startup screen (your last question answered by implementation): the app now launches into a front-door window — Create / Open / content library — and the immersive space opens only on user intent. Structurally it's Hello World's single-window pattern: the front door, the authoring menu, and the player's transport bar are three content states of ONE persistent Window scene, not separate windows. (We tried a dedicated startup window with hand-offs first; the single-window design proved far cleaner.) On your side-by-side question: verified on-device — at the front door (no space open), other apps' windows coexist normally; entering a scene hides them (both our modes are immersive spaces — even Passthrough is .mixed); exiting back to the front door brings them back. During editing itself, full immersion IS the product — a spatial 3D editor — so that stays immersive-first by design.

  • The Objects-in-Orbit attachment pattern for the "main window closed while immersed" state: a persistent ViewAttachmentComponent with BillboardComponent and conditional content, offering Reopen plus an Exit that returns to the startup window. One practical note: we place it at 0.85 m — at 1.0–1.3 m it can sit behind the user's own tool windows, and depth compositing is honest.

  • exit(0) is gone. Message received on programmatic quit being unsupported. "Quitting" is now: exit to the startup window → user closes it → zero scenes → the system recedes the app. (Your Solar System module's Exit button was a helpful precedent.)

  • On your remaining question — the file-launch difference: scenes can be authored with a play-on-open flag, so another Vision Pro user tapping a shared scene file gets a playback experience — watching the created animation via a compact transport bar, the Solar System pattern — rather than the authoring UI. Deliberate authorial intent, not an inconsistency.

Findings from the implementation your team may find useful:

  1. defaultLaunchBehavior(.presented) doesn't cover RE-ACTIVATION of a running app: with the main window closed and tool windows alive, an icon tap re-presents only the tool windows, .presented notwithstanding. We recover via a UIApplication.didBecomeActiveNotification observer that reopens the main window — works, but feels like it should be the launch-behavior API's job.

  2. The last-closed-window zombie shaped most of our bugs: a window closed as the last regular scene is backgrounded, never eliminated — no onDisappear, and it still counts as "open." Practical corollary we learned the hard way: programmatic dismissWindow teardowns must be ordered so every dismissal has an open sibling, or scenes zombify and the app can't recede. Your lifecycle sample's three-state enum (.inTransition / .open(phase) / .closed) turned out to be essential — we think it might deserve louder billing in the docs.

  3. An immersion-style transition asymmetry worth a look: the .immersionStyle(selection:) value is read at presentation, and a .mixed → .full restyle applied to a just-opened space fails reliably on-device (space renders pure black; .full → .mixed tolerates the same timing). We fixed it by ensuring the selection binding is correct before openImmersiveSpace (computed binding reading our model live). Happy to file a Feedback with a repro if useful.

  4. An open question on the earlier scenePhase discussion: the ~61 s out-of-view backgrounding seems CONDITIONAL, and we've been unable to pin the conditions experimentally. Our parked main window reliably backgrounds after ~a minute in BOTH immersion styles, with or without other windows open; your Orbit module's lone parked window stayed .active for 3+ minutes. Ruled out experimentally: immersion style (both behave the same), presence of other windows (none open, still backgrounds), and the window's own update activity (we made our menu fully static — still backgrounds on schedule). Whatever the discriminating condition is, it isn't observable from the app side. If the rules were documented, apps relying on the show-benign-UI-on-background pattern could set expectations correctly.

Thanks again for the direction — the samples were exactly the map we needed. FB24076974 remains our enhancement request for a close signal on the last nonimmersive scene; everything else above is running today.

Accepted Answer

Hey @RichBlum,

Thanks so much for the update. It was great to hear that I was able to point you in the right direction. Super excited to see these learnings implemented. I also appreciate you sharing your additional findings.

  • You are experiencing the correct behavior of defaultLaunchBehavior(.presented). You highlight a need for a new API for a behavior on app activation when the app is already running. Please file an enhancement request using Feedback Assistant for this request.
  • Thanks for this callout, we'll consider options on how we can better communicate this with developers.
  • Please do file a feedback with a repro. Feedback Requests are always looked at and valuable to all teams at Apple. Additionally, if you could post the FB number here when you do, I'd love to take a look at it as well.
  • Please avoid relying on undocumented behavior. Please file an enhancement request if there's an API you'd like to see implemented. With additional information I may be able to recommend alternative approaches.

Thanks,
Michael

Michael,

Filed: FB24152895 — the enhancement request for scene presentation on app re-activation (the defaultLaunchBehavior gap discussed above).

On the immersion-style restyle: On the immersion-style restyle: in the spirit of not wasting your time, I built a minimal repro project first, and in isolation, the .mixed → .full flip 300 ms after opening transitions correctly. So the black-render we hit required more than the restyle timing alone; the missing ingredient is presumably work our renderer performs during the transition (IBL environment application, a large textured sky dome). Since ensuring the selection is correct at presentation fixed our app outright, I can't currently produce a failing repro worth your time. If it ever resurfaces, I'll capture it and file then.

On avoiding undocumented behavior — point taken, and let me be precise about the two things in play. The out-of-view backgrounding timing: nothing in the app depends on it. The in-space affordance is deliberately benign precisely so the timing can be anything, so there's no fragility there by design. The activation-observer recovery is the other piece: as far as we can tell it uses only documented API (didBecomeActiveNotification plus openWindow(id:)), but if what you're flagging is a reliance on an activation sequence that isn't contractually guaranteed, I'd rather know than assume. That's the one piece I'd most welcome a supported alternative for, if one exists. The offer you made to recommend alternative approaches is genuinely appreciated, and this is where it would help most.

Thanks again, Rich

visionOS 26: is there any way yet to distinguish a user-initiated window close from system out-of-FoV backgrounding
 
 
Q