Back to Blog

Flutter iOS Push Notification Deep Links from Terminated State — A UIScene Lifecycle Gotcha

Flutter iOS Push Notification Deep Links — UIScene Lifecycle Gotcha

If you've migrated your Flutter iOS app to the UIScene lifecycle (now the default since Flutter 3.41) and your push notification deep links stopped working from terminated state — you're not alone. Here's what's happening and how to fix it.


The Symptom

Everything works perfectly when the app is in the background. But when the app is fully killed and the user taps a notification banner, the app launches to the default screen instead of the expected deep link destination.

FirebaseMessaging.instance.getInitialMessage() returns null. onMessageOpenedApp never fires. Your custom UNUserNotificationCenterDelegate.didReceive handler in AppDelegate? Also not called.


The Root Cause

With the UIScene lifecycle, iOS changes how terminated-state notification taps are delivered. The tap payload is no longer reliably sent through UNUserNotificationCenterDelegate.didReceive on cold start. Instead, it's delivered via:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    // This is where your notification tap lives now:
    connectionOptions.notificationResponse
}

This is consistent with Apple's broader UIScene migration pattern — launch context moves from AppDelegate to SceneDelegate. The firebase_messaging Flutter plugin hasn't fully adapted to this yet, which is why getInitialMessage() returns null.


The Fix

1. Capture the tap in SceneDelegate

Override scene(_:willConnectTo:options:) in your SceneDelegate.swift and persist the notification payload for your Flutter code to pick up:

class SceneDelegate: FlutterSceneDelegate {
    override func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
                        options connectionOptions: UIScene.ConnectionOptions) {
        super.scene(scene, willConnectTo: session, options: connectionOptions)

        if let response = connectionOptions.notificationResponse {
            let userInfo = response.notification.request.content.userInfo
            // Extract your payload and persist it (UserDefaults, a static var,
            // or send via MethodChannel once the engine is ready)
        }
    }
}

2. Bridge to Dart via MethodChannel

Use didInitializeImplicitFlutterEngine in your AppDelegate to create a MethodChannel and forward the persisted payload to Dart once the engine is ready.

3. Handle the timing on the Dart side

This is the subtle part. On cold start, your notification payload arrives in Dart before your router/navigator is mounted. If you try to navigate immediately, the router doesn't exist yet and the tap is silently dropped.

The solution: buffer the tap and defer navigation until after your router widget has mounted (e.g., via addPostFrameCallback after the router is created in your widget tree).


The Timing Problem Visualized

❌ What was happening:
   Payload arrives → try to navigate → router is null → tap lost

✅ What should happen:
   Payload arrives → buffer it → auth completes → router mounts → navigate

Key Takeaways

  1. UIScene changes notification delivery. didReceive is not reliably called on cold start. Use connectionOptions.notificationResponse in your SceneDelegate.
  2. getInitialMessage() is broken under UIScene. The firebase_messaging plugin hasn't adapted. You need a native MethodChannel workaround.
  3. Cold-start navigation has a timing gap. Your router/navigator doesn't exist when the payload first arrives in Dart. Buffer the tap and defer navigation until the widget tree is ready.
  4. Deduplication matters. With belt-and-suspenders approaches (SceneDelegate + AppDelegate fallback), the same tap can arrive twice. Deduplicate by message ID on the Dart side.

Affected Versions

  • Flutter 3.38+ (UIScene default)
  • firebase_messaging 15.x
  • iOS 17+
  • Any app using FlutterSceneDelegate with UIApplicationSceneManifest in Info.plist

TL;DR

If your Flutter iOS push notification deep links broke after the UIScene migration: check connectionOptions.notificationResponse in your SceneDelegate, bridge it to Dart via a MethodChannel, and make sure you don't try to navigate before your router is mounted.

Spent two days debugging this. Hopefully this saves someone else the trouble.