authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 11:12:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 11:12:40-07:00
log4c61e3d7ecc4d9957c243677d35dd3313a37c067
treee431abc0b6ace60f1769c42ad0f2827780c31488
parentdcdf5e6a013607b42836a270165286181da48296
signature Signed by PGP key B5690EEEBB952194

fix(mobile): synchronize native notification delegates (#12483)


6 files changed, 379 insertions(+), 4 deletions(-)

apps/mobile/package.json+1-1
......@@ -96,7 +96,7 @@
9696 "expo-keep-awake": "~57.0.2",
9797 "expo-linking": "~57.0.8",
9898 "expo-network": "~57.0.1",
99 "expo-notifications": "~57.0.15",
99 "expo-notifications": "57.0.15",
100100 "expo-paste-input": "^0.1.15",
101101 "expo-quick-actions": "^6.0.2",
102102 "expo-secure-store": "~57.0.2",
apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift created+189
......@@ -0,0 +1,189 @@
1import Foundation
2
3// The registry runs on macOS without starting a simulator. Only the OS-facing
4// types are replaced; the tests compile the dependency's actual Swift source.
5public enum UIBackgroundFetchResult {
6 case noData
7}
8
9public struct UNNotificationPresentationOptions: OptionSet {
10 public let rawValue: Int
11 public init(rawValue: Int) { self.rawValue = rawValue }
12}
13
14public final class UNNotification: NSObject {}
15
16public final class UNNotificationResponse: NSObject {
17 let identifier: String
18 init(_ identifier: String) { self.identifier = identifier }
19}
20
21public protocol UNUserNotificationCenterDelegate: AnyObject {}
22
23public final class UNUserNotificationCenter: NSObject {
24 private static let instance = UNUserNotificationCenter()
25 public weak var delegate: UNUserNotificationCenterDelegate?
26 public static func current() -> UNUserNotificationCenter { instance }
27}
28
29private final class TestDelegate: NotificationDelegate {
30 private let lock = NSLock()
31 private var recordedEvents: [String] = []
32 var onEvent: ((String) -> Void)?
33 var onResponse: ((UNNotificationResponse) -> Bool)?
34
35 var events: [String] { lock.withLock { recordedEvents } }
36
37 private func record(_ event: String) {
38 lock.withLock { recordedEvents.append(event) }
39 onEvent?(event)
40 }
41
42 func didRegister(_ deviceToken: String) { record("registered") }
43 func didFailRegistration(_ error: Error) { record("failed") }
44 func openSettings(_ notification: UNNotification?) { record("settings") }
45
46 func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
47 record("present")
48 return false
49 }
50
51 func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
52 record("background")
53 return false
54 }
55
56 func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
57 record(response.identifier)
58 return onResponse?(response) ?? false
59 }
60}
61
62private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
63 if !condition() {
64 throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
65 }
66}
67
68private func deliver(_ identifier: String) {
69 NotificationCenterManager.shared.userNotificationCenter(
70 UNUserNotificationCenter.current(),
71 didReceive: UNNotificationResponse(identifier),
72 withCompletionHandler: {}
73 )
74}
75
76private func reentrantCallbacks() throws {
77 let manager = NotificationCenterManager.shared
78 let center = UNUserNotificationCenter.current()
79 let callbacks: [(String, () -> Void)] = [
80 ("registered", { manager.didRegister("token") }),
81 ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }),
82 ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }),
83 ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }),
84 ("background", { manager.didReceive([:], completionHandler: { _ in }) })
85 ]
86
87 for (event, callback) in callbacks {
88 let original = TestDelegate()
89 let replacement = TestDelegate()
90 original.onEvent = { [weak original] _ in
91 guard let original else { return }
92 manager.removeDelegate(original)
93 manager.addDelegate(replacement)
94 }
95 manager.addDelegate(original)
96 callback()
97 try require(original.events == [event], "The original delegate must receive the callback once")
98 try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot")
99 callback()
100 try require(replacement.events == [event], "Reentrant registration must take effect for the next callback")
101 manager.removeDelegate(replacement)
102 }
103}
104
105private func registrationDuringDelivery() throws {
106 let manager = NotificationCenterManager.shared
107 let original = TestDelegate()
108 let receiver = TestDelegate()
109 receiver.onResponse = { _ in true }
110 original.onResponse = { _ in
111 manager.addDelegate(receiver)
112 return false
113 }
114 manager.addDelegate(original)
115 deliver("handoff")
116 try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response")
117 manager.removeDelegate(original)
118 manager.removeDelegate(receiver)
119}
120
121private func responseDuringReplay() throws {
122 let manager = NotificationCenterManager.shared
123 deliver("first")
124 let original = TestDelegate()
125 original.onResponse = { response in
126 if response.identifier == "first" {
127 deliver("second")
128 return true
129 }
130 return false
131 }
132 manager.addDelegate(original)
133 let receiver = TestDelegate()
134 receiver.onResponse = { _ in true }
135 manager.addDelegate(receiver)
136 try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks")
137 manager.removeDelegate(original)
138 manager.removeDelegate(receiver)
139}
140
141private func concurrentRegistrations() throws {
142 let manager = NotificationCenterManager.shared
143 let delegates = (0..<512).map { _ in
144 let delegate = TestDelegate()
145 delegate.onResponse = { _ in true }
146 return delegate
147 }
148 DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
149 manager.addDelegate(delegates[index])
150 if index.isMultiple(of: 16) {
151 manager.didRegister("during-add")
152 deliver("during-add")
153 }
154 }
155 let before = delegates.map { $0.events.count }
156 manager.didRegister("after-add")
157 for (index, delegate) in delegates.enumerated() {
158 try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once")
159 }
160 DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
161 manager.removeDelegate(delegates[index])
162 if index.isMultiple(of: 16) {
163 manager.didRegister("during-remove")
164 deliver("during-remove")
165 }
166 }
167 let removed = delegates.map { $0.events.count }
168 manager.didRegister("after-remove")
169 try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks")
170}
171
172@main
173private enum RegressionTests {
174 static func main() {
175 do {
176 switch CommandLine.arguments.last {
177 case "reentrant": try reentrantCallbacks()
178 case "handoff": try registrationDuringDelivery()
179 case "pending": try responseDuringReplay()
180 case "concurrent": try concurrentRegistrations()
181 default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2)
182 }
183 print("passed")
184 } catch {
185 FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
186 exit(1)
187 }
188 }
189}
apps/mobile/scripts/notification-center-manager.test.ts created+65
......@@ -0,0 +1,65 @@
1// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
8
9// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler.
10describe.skipIf(NodeOS.platform() !== "darwin")(
11 "NotificationCenterManager native concurrency",
12 () => {
13 let directory: string;
14 let executable: string;
15
16 beforeAll(() => {
17 directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-"));
18 executable = NodePath.join(directory, "notification-regression");
19 const source = NodeFS.readFileSync(
20 new URL(
21 "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift",
22 import.meta.url,
23 ),
24 "utf8",
25 );
26 const manager = NodePath.join(directory, "NotificationCenterManager.swift");
27 NodeFS.writeFileSync(
28 manager,
29 source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""),
30 );
31 NodeChildProcess.execFileSync(
32 "swiftc",
33 [
34 "-swift-version",
35 "5",
36 "-sanitize=thread",
37 manager,
38 NodeURL.fileURLToPath(
39 new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url),
40 ),
41 "-o",
42 executable,
43 ],
44 { timeout: 30_000, encoding: "utf8" },
45 );
46 });
47
48 afterAll(() => {
49 if (directory) NodeFS.rmSync(directory, { recursive: true, force: true });
50 });
51
52 it.each([
53 ["reentrant", "allows callbacks to replace delegates without deadlocking"],
54 ["handoff", "delivers responses to delegates registering during delivery"],
55 ["pending", "retains new responses received while replaying pending responses"],
56 ["concurrent", "registers, removes, and broadcasts concurrently without data races"],
57 ])("%s: %s", (name) => {
58 const output = NodeChildProcess.execFileSync(executable, [name], {
59 encoding: "utf8",
60 timeout: 15_000,
61 });
62 expect(output.trim()).toBe("passed");
63 });
64 },
65);
patches/expo-notifications@57.0.15.patch created+119
......@@ -0,0 +1,119 @@
1diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift
2--- a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift
3+++ b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift
4@@ -42,8 +42,10 @@
5 @objc
6 public static let shared = NotificationCenterManager()
7
8- var delegates: [NotificationDelegate] = []
9- var pendingResponses: [UNNotificationResponse] = []
10+ // Module creation and destruction can overlap across React runtimes.
11+ private let stateLock = NSLock()
12+ private var delegates: [NotificationDelegate] = []
13+ private var pendingResponses: [UNNotificationResponse] = []
14 let userNotificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current()
15
16 private override init() {
17@@ -62,32 +64,49 @@
18 }
19
20 public func addDelegate(_ delegate: NotificationDelegate) {
21- delegates.append(delegate)
22+ let responses = stateLock.withLock {
23+ delegates.append(delegate)
24+ return pendingResponses
25+ }
26 var handled = false
27- for pendingResponse in pendingResponses {
28+ for pendingResponse in responses {
29 handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled
30 }
31 if handled {
32- pendingResponses.removeAll()
33+ removePendingResponses(responses)
34 }
35 }
36
37 public func removeDelegate(_ delegate: AnyObject) {
38- if let index = delegates.firstIndex(where: { $0 === delegate }) {
39- delegates.remove(at: index)
40+ stateLock.withLock {
41+ if let index = delegates.firstIndex(where: { $0 === delegate }) {
42+ delegates.remove(at: index)
43+ }
44+ }
45+ }
46+
47+ private func snapshotDelegates() -> [NotificationDelegate] {
48+ stateLock.withLock { delegates }
49+ }
50+
51+ private func removePendingResponses(_ responses: [UNNotificationResponse]) {
52+ let identifiers = Set(responses.map(ObjectIdentifier.init))
53+ stateLock.withLock {
54+ // Keep responses received while delegate callbacks were running.
55+ pendingResponses.removeAll { identifiers.contains(ObjectIdentifier($0)) }
56 }
57 }
58
59 // MARK: - Called by PushTokenAppDelegateSubscriber
60
61 public func didFailRegistration(_ error: any Error) {
62- for delegate in delegates {
63+ for delegate in snapshotDelegates() {
64 delegate.didFailRegistration(error)
65 }
66 }
67
68 public func didRegister(_ deviceToken: String) {
69- for delegate in delegates {
70+ for delegate in snapshotDelegates() {
71 delegate.didRegister(deviceToken)
72 }
73 }
74@@ -100,7 +119,7 @@
75 withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
76 ) {
77 var handled = false
78- for delegate in delegates {
79+ for delegate in snapshotDelegates() {
80 handled = delegate.willPresent(notification, completionHandler: completionHandler) || handled
81 }
82 if !handled {
83@@ -113,18 +132,23 @@
84 didReceive response: UNNotificationResponse,
85 withCompletionHandler completionHandler: @escaping () -> Void
86 ) {
87+ let currentDelegates = stateLock.withLock {
88+ // A delegate registering during delivery must see this response too.
89+ pendingResponses.append(response)
90+ return delegates
91+ }
92 var handled = false
93- for delegate in delegates {
94+ for delegate in currentDelegates {
95 handled = delegate.didReceive(response, completionHandler: completionHandler) || handled
96 }
97- if !handled {
98- pendingResponses.append(response)
99+ if handled {
100+ removePendingResponses([response])
101 }
102 completionHandler()
103 }
104
105 public func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
106- for delegate in delegates {
107+ for delegate in snapshotDelegates() {
108 delegate.openSettings(notification)
109 }
110 }
111@@ -132,7 +156,7 @@
112 // MARK: - Called from NotificationsAppDelegateSubscriber
113 public func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
114 var handled = false
115- for delegate in delegates {
116+ for delegate in snapshotDelegates() {
117 handled = delegate.didReceive(userInfo, completionHandler: completionHandler) || handled
118 }
119 if !handled {
pnpm-lock.yaml+4-3
......@@ -103,6 +103,7 @@ patchedDependencies:
103103 effect@4.0.0-rc.115: 0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5
104104 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a
105105 expo-modules-core@57.0.14: 6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e
106 expo-notifications@57.0.15: f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40
106107 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
107108 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6
108109 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2
......@@ -389,8 +390,8 @@ importers:
389390 specifier: ~57.0.1
390391 version: 57.0.1(expo@57.0.18)(react@19.2.3)
391392 expo-notifications:
392 specifier: ~57.0.15
393 version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2)
393 specifier: 57.0.15
394 version: 57.0.15(patch_hash=f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2)
394395 expo-paste-input:
395396 specifier: ^0.1.15
396397 version: 0.1.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
......@@ -17449,7 +17450,7 @@ snapshots:
1744917450 expo: 57.0.18(41fd11498a34454c91128cdad32f22f8)
1745017451 react: 19.2.3
1745117452
17452 expo-notifications@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2):
17453 expo-notifications@57.0.15(patch_hash=f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2):
1745317454 dependencies:
1745417455 '@expo/image-utils': 0.11.5(typescript@7.0.2)
1745517456 abort-controller: 3.0.0
pnpm-workspace.yaml+1
......@@ -190,6 +190,7 @@ patchedDependencies:
190190 effect@4.0.0-rc.115: patches/effect@4.0.0-rc.115.patch
191191 expo-audio@57.0.4: patches/expo-audio@57.0.4.patch
192192 expo-modules-core@57.0.14: patches/expo-modules-core@57.0.14.patch
193 expo-notifications@57.0.15: patches/expo-notifications@57.0.15.patch
193194 expo-sharing@57.0.17: patches/expo-sharing@57.0.17.patch
194195 expo-widgets@57.0.15: patches/expo-widgets@57.0.15.patch
195196 react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch