Skip to content

fix: retain iOS overlay window during interactive dismissal - #263

Merged
originalix merged 2 commits into
mainfrom
codex/fix-ios-overlay-window-handoff
Nov 13, 2026
Merged

originalix merged 2 commits into
mainfrom
codex/fix-ios-overlay-window-handoff

Conversation

@huhuanming

@huhuanming huhuanming commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

问题与复现

iOS 账户选择器与右上角 More 是两个不同的窗口层:账户选择器由 App 主窗口承载,More 由 NativeOverlay 的独立 UIWindow 承载。在 iOS 27.0 模拟器、三账户测试钱包中:

  1. 打开账户选择器,再打开 More。
  2. 在 More 卡片内连续上下往返多次;另做一次部分下拉,停住后松手,确认卡片自行回弹。
  3. 再单向下拉并松手关闭 More;约 0.25 秒后立即向下拖账户选择器并松手。

修复前,同一短间隔轨迹在两次干净 App 运行中均使账户选择器停在约下移 171 pt 的位置;UIKit transitionCoordinator 仍为 isInteractive = YES、percentComplete ≈ 20.94%。再次下拉后可停在约 57.3%,无法正常关闭。只把 More 结束到后层开始的间隔改为 1.25 秒,后层则正常退出(1/1)。JS 路由查询在悬停时仍响应,因此这里不把现象描述为整个 App 主线程死锁。

原因分析

旧实现收到 More entry 移除时立即隐藏并释放它的窗口、root controller 和 scene context,按既有规则处理 key window 归还。短间隔下,UIKit 已将后层触摸的前 7 个 UIEvent 送往主窗口,但它们仍带旧 overlay context 标记;旧窗口 context 被注销后,同一段输入的后续 29 个事件未还原为 UIEvent,日志改为直接分派 backing event。这与交互转场悬停的时间线一致。UIKit 日志未直接给出 pan recognizer 的 ended/cancelled phase,因此不能据此断言系统已明确丢弃 UP,也不能将模拟器证据直接归因为用户真机的唯一原因。

另做了只在测试进程内将 resignKey() 调用 refreshReactNativeDimensions() 的指令临时替换为 NOP 的诊断对照;同一短间隔轨迹仍悬停。这只排除该 helper 调用对本次复现的必要性,未改磁盘依赖或源码,并已通过重启恢复原指令。

修复

  • entry 从 active owner 顺序中立即移除,并保持既有 key window 归还规则;若 App 窗口当前存在 interactive UIKit presentation,暂存该退出窗口本身,保留其 root/scene/context。
  • 使用 notifyWhenInteractionChanges,在交互结束或取消后下一次 main turn 重新检查当前 coordinator;若又进入交互,则继续等待。按退休窗口身份幂等清理,旧回调不会释放同一 owner 重开的新窗口。
  • scene disconnect 同时释放 active 与 retired 窗口,即使只剩 retired 窗口也能清理,且不重复通知已移除的 owner。没有固定延时、新 public API 或依赖;Android、Web 和 App 业务调用未改。

目标平台是 iOS 的 main UI。窗口管理器是进程内、主线程使用的 UIKit 资源;iOS bg 有独立 JS heap 和初始化时序,本案没有 bg 参与证据,也没有数据反序列化路径变更。

验证

  • tests/ios/run.sh:11/11 通过。新增的 4 个用受控 transition coordinator 验证 manager 的完成、取消与重入、同 owner 重开、scene disconnect 生命周期;它们不代替真实手势验证。
  • iOS 27.0 模拟器中的修复版 App:原短间隔完整轨迹 3/3 正常关闭至 Home;1.25 秒对照 1/1 正常关闭;短间隔低速部分下拉 1/1 自动回到选择器顶部,随后 More 可重开、完整下拉可关闭,Home/Market 可操作。
  • 第一轮短间隔修复版系统日志中,同一旧 context 标记的后层 36/36 个 UIEvent 均送到主窗口,最后事件后约 27 ms 才完成旧窗口 context 清理。

录屏

以下均为 iOS 27.0 模拟器、三账户测试钱包的原始实测录像。

修复前:完整复现

More 多次往返、部分下拉松手自行回弹,再关闭 More 并紧接着拖后层,账户选择器停在中段。

multi-wiggle-half-rebound-case1.mp4

修复前:继续下拉仍无法关闭

在同一异常现场继续下拉,松手后仍停在半路。

multi-wiggle-half-rebound-case1-retry-drag.mp4

修复后:相同短间隔轨迹正常关闭

保持 More 结束到后层开始的 0.25 秒输入间隔,账户选择器正常关闭至 Home。

owner-fix-short1.mp4

修复后:部分下拉松手自动回弹

后层低速部分下拉后松手,账户选择器自行回到原位。

owner-fix-short-cancel-retry2-valid.mp4

验证边界

验证范围仅为 iOS 27.0 模拟器及上述局部手势;真机、iPad 与更广运行矩阵尚未验证。此 SDK 源码修复还需要后续 SDK 与 App 原生发版才能进入用户安装包;本 PR 不发包。

CI 跟进

原 ios-native 作业失败于 NativeList 的 testVisibleVideoReleasesPlayerAfterCapturingFirstFrame:截止时加载回调与首帧仍为空;ios-xctest 因上游失败连带失败。该测试没有调用本次修改的 overlay 窗口管理器。

提交 364ff1735 仅修正现有 XCTest 夹具:将两个视频预览挂到 AppHost 已显示窗口中的临时容器,绑定前检查窗口归属和视口,退出时移除容器。保留原首帧成功、64×48 尺寸、播放器释放和缓存复用断言,以及原 10/3/3 秒截止时间;没有修改 NativeList 业务代码。日志不足以断言 key 状态就是旧失败的唯一原因。

  • 本机 iPhone 17 Pro / iOS 26.0:该真实 AVPlayer 用例 1/1 通过,1.506 秒,0 失败、0 跳过。
  • 提交 364ff1735 的当前 15 项 GitHub checks 全部通过,包括 ios-native、ios-xctest 和 device-utils-store。Hosted iOS 作业 中,此前失败的首帧用例通过(1.794 秒);NativeList 套件共 88 项、1 项既有跳过、0 失败。
  • 窗口生命周期实现及测试夹具已独立复核,未发现阻塞问题;当前 GitHub 没有待解决的 review 线程。

@huhuanming
huhuanming enabled auto-merge (squash) November 13, 2026 07:32
@sidmorizon

Copy link
Copy Markdown
Contributor

Devin Cloud review ↗

@sidmorizon

Copy link
Copy Markdown
Contributor

@codex review

@sidmorizon

Copy link
Copy Markdown
Contributor

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T08:08:40.580031Z 364ff17 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sidmorizon

Copy link
Copy Markdown
Contributor

Cursor review failed.

Remote error: cursor_permission_denied: HTTP 403 Forbidden: Cloud Agent is not available for free users. Please upgrade to Pro.

@cursor

cursor Bot commented Nov 13, 2026

Copy link
Copy Markdown

Skipping Bugbot: Bugbot is disabled for this repository. Visit the Bugbot dashboard to update your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 364ff17359

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sidmorizon

Copy link
Copy Markdown
Contributor

@originalix
originalix disabled auto-merge November 13, 2026 08:32
@originalix
originalix merged commit de8781d into main Nov 13, 2026
15 checks passed
@originalix
originalix deleted the codex/fix-ios-overlay-window-handoff branch November 13, 2026 08:32
@huhuanming

Copy link
Copy Markdown
Contributor Author

PR #263 代码审查报告

审查概要

  • 变更范围: 4 个文件, +237 / -20 行
  • 风险等级: Medium
  • 涉及平台: Mobile (iOS)
  • PR 评论分析: ✅ 已分析 (9 条评论, 其中 4 条来自 Bot)

运行时拓扑与复现/验收条件核对

  • 目标平台与运行时范围: iOS main(UI 主线程)。
  • 原生资源归属与 JS 堆拷贝: NativeOverlayWindowManager 是 iOS 进程内主线程持有的 UIKit 窗口管理单例;不涉及 bg 独立 JS 运行时、跨运行时通信或数据反序列化变更。
  • 复现条件 (Repro): 在 iOS 27.0 模拟器打开账户选择器(承载于 appWindow),再打开右上角 More(承载于 NativeOverlay 独立 UIWindow);在 More 内多次上下滑动及部分下拉回弹后,单向下拉松手关闭 More,约 0.25s 后立即下拉底层的账户选择器并松手。
  • 修复前异常条件 (Non-passing): More 退场完成触发 entryRemoved(from:) 立即销毁旧 UIWindow(isHidden = true、rootViewController = nil、windowScene = nil),导致已路由到 appWindow 但仍带旧 overlay window context 标记的触摸流中断,后续触摸样本不再合成为 UIEvent,账户选择器交互式转场卡死在 isInteractive = YES(percentComplete ≈ 20.94%)。
  • 修复后通过条件 (Final pass): 当 entryRemoved(from:) 检测到 appWindow 存在交互式转场(transitionCoordinator.isInteractive == true)时,立即从活跃列表中移除该 entry 并归还 key window,但将退场 UIWindow 暂存至 retiredWindows,通过 notifyWhenInteractionChanges 等待交互结束或取消并在下一主线程 RunLoop turn 复核无新交互后再释放窗口上下文;tests/ios/run.sh 11/11 通过,iOS 27.0 模拟器 0.25s 短间隔手势 3/3 正常关闭至 Home。

评分

维度 得分 说明
🔒 安全性 9/10 纯客户端 UIKit 窗口生命周期与测试夹具改动,无敏感信息、权限或外部输入风险;RetiredWindow 对 appWindow、observedCoordinator 及闭包捕获均使用 weak,无循环引用或内存泄漏风险。
💎 代码质量 9/10 根因分析扎实,使用 UIKit 原生 notifyWhenInteractionChanges + 下一主线程 turn 复核替代脆弱的固定延时;完整处理了同 coordinator 交互重入、同 owner 重开新窗口、scene disconnect 幂等清理。
🏛️ 架构合理性 9/10 状态收敛在 NativeOverlayWindowManager.swift 内部,未破坏公共 API 或跨平台接口。顺带修复了 NativeList XCTest 夹具的窗口挂载问题并在 PR 描述中完整说明。
✅ 完整性 9/10 同步更新了 SPEC.md,补充了 4 个受控转场协调器生命周期用例(11/11 通过)及模拟器实测录屏证据。
总分 9.0/10 ✅ 可直接合入

发现的问题

本次变更未发现 P0 或 P1 级别的阻塞性缺陷。核心生命周期设计(按 ObjectIdentifier(entry.window) 而非 ownerKey 追踪退休窗口、立即归还 key window、sceneDidDisconnect 同时清理 active 与 retired 窗口且不重复通知已移除 owner)逻辑严密。以下为 2 项 P2 边界增强建议:

[P2] [🟠 Medium] interactiveCoordinator(in:) 仅遍历 presentedViewController 链,未覆盖子容器控制器(children)的交互式转场

文件: native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift:354-363
类型: 运行时
描述:
interactiveCoordinator(in:) 当前仅沿 current.presentedViewController 链查找 transitionCoordinator。在 UIKit 中,UIViewController.transitionCoordinator 默认会向 parent 向上查询,但不会向 children 向下查询。对于本次修复的账户选择器(模态 Sheet,位于 presentedViewController 链上)完全生效;但如果 appWindow.rootViewController(或某层 presented VC)下作为子控制器挂载了 UINavigationController(例如 react-native-screens 的 RNSScreenStack),当用户在 Overlay 窗口退场瞬间紧接着触发导航栈原生侧滑返回(interactivePopGestureRecognizer)或 definesPresentationContext = true 的子容器交互转场时,appWindow.rootViewController.transitionCoordinator 为 nil,此时仅遍历 presentedViewController 会漏检该子容器上的交互式协调器。

Auto-fix:

   private static func interactiveCoordinator(in appWindow: UIWindow?) -> UIViewControllerTransitionCoordinator? {
-    var controller = appWindow?.rootViewController
-    while let current = controller {
+    guard let root = appWindow?.rootViewController else { return nil }
+    var queue: [UIViewController] = [root]
+    var visited = Set<ObjectIdentifier>()
+    while let current = queue.first {
+      queue.removeFirst()
+      guard visited.insert(ObjectIdentifier(current)).inserted else { continue }
       if let coordinator = current.transitionCoordinator, coordinator.isInteractive {
         return coordinator
       }
-      controller = current.presentedViewController
+      queue.append(contentsOf: current.children)
+      if let presented = current.presentedViewController {
+        queue.append(presented)
+      }
     }
     return nil
   }

[P2] [⚪ Low] testVisibleVideoReleasesPlayerAfterCapturingFirstFrame 异常提前退出时未在 defer 中执行 recycle()

文件: native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift:60-112
类型: 规范
描述:
提交 364ff1735 将视频首帧捕获测试改为挂载到宿主 App 已显示的 key window 容器中,并在第 59 行通过 defer { container.removeFromSuperview() } 保证视图移除。但 slot.recycle() 与 second.recycle() 仅写在测试末尾第 111 行;若第 75 行 try XCTUnwrap(slot.capturedFrame) 因超时抛出异常,测试提前退出时不会执行 slot.recycle() 来立即释放缓存订阅与租约状态。放入 defer 可确保断言失败时也彻底清理。

Auto-fix:

     let slot = NativeListMediaPreviewSlot()
+    defer { slot.recycle() }
     slot.view.frame = CGRect(x: 0, y: 0, width: 120, height: 120)
...
     let second = NativeListMediaPreviewSlot()
+    defer { second.recycle() }
     second.view.frame = CGRect(x: 120, y: 0, width: 120, height: 120)
...
     XCTAssertEqual(second.createdPlayerCount, 0, "A different cell uses shared cached pixels")
-    second.recycle(); slot.recycle()
   }

修改清单

优先级 置信度 文件 类型 描述 Auto-fix
P2 🟠 Medium NativeOverlayWindowManager.swift:354-363 运行时 interactiveCoordinator(in:) 遍历 children 以覆盖子导航栈侧滑返回等交互转场 ✅
P2 ⚪ Low NativeListMediaVisibilityTests.swift:60-112 规范 将 slot.recycle() / second.recycle() 移入 defer 确保断言失败时也能清理 ✅

测试建议

  1. iOS 真机手势回归: 在 iOS 真机(120Hz ProMotion 设备)上复测「关闭 NativeOverlay Sheet(如 More)后 0.1s–0.3s 内立即下拉底层系统 Sheet(如账户选择器)或执行页面侧滑返回」,确认真机 BackBoard 事件分派下无悬停卡顿。
  2. 连续快速重开同一 Overlay: 在底层 Sheet 处于交互式下拉过程中,快速触发同一 NativeOverlay(或 Toast)关闭再打开,验证新窗口可正常交互且旧窗口在手势结束后正常释放。
  3. iPad 多窗口 / Stage Manager 场景: 在 iPad 分屏或关闭窗口触发 UIScene.didDisconnectNotification 时,确认处于 retiredWindows 状态的窗口被干净回收且无崩溃。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants