From 8fffa5d67693b07fda23a2b7d73cc3ae92dedf60 Mon Sep 17 00:00:00 2001 From: defia Date: Mon, 14 Sep 2026 16:47:35 +0800 Subject: [PATCH] fix(pane): preserve deferred mount recovery claims --- Glint/Pane/PaneSurfaceRepresentable.swift | 10 +++++- GlintTests/PerformanceRegressionTests.swift | 37 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Glint/Pane/PaneSurfaceRepresentable.swift b/Glint/Pane/PaneSurfaceRepresentable.swift index e313ff56..ef565934 100644 --- a/Glint/Pane/PaneSurfaceRepresentable.swift +++ b/Glint/Pane/PaneSurfaceRepresentable.swift @@ -306,8 +306,16 @@ struct PaneSurfaceRepresentable: NSViewRepresentable { // back. Resolve that ambiguity after the current commit: the new // host will be attached if it survived, or still detached if the // older host genuinely needs to recover it. + // Deferral is also a pending claim: the candidate may mount + // after this callback, or be reused before it runs. Preserve the + // mount recovery and invalidate callbacks superseded by reuse. + armPendingRecovery(surface, host: container, visible: isPaneVisible) + let epoch = surface.pendingRecoveryEpoch DispatchQueue.main.async { - guard container.window != nil, isPaneVisible() else { return } + guard surface.pendingRecoveryEpoch == epoch, + surface.pendingRecoveryHost === container, + container.pendingRecoverySurface === surface, + container.window != nil, isPaneVisible() else { return } performAttach(surface, to: container, isPaneVisible: isPaneVisible, pass: .postCommitDeferred) } diff --git a/GlintTests/PerformanceRegressionTests.swift b/GlintTests/PerformanceRegressionTests.swift index 669c49ef..52888ffa 100644 --- a/GlintTests/PerformanceRegressionTests.swift +++ b/GlintTests/PerformanceRegressionTests.swift @@ -520,6 +520,43 @@ final class PerformanceRegressionTests: XCTestCase { XCTAssertNil(surfaceA.pendingRecoveryHost) } + func testDeferredCandidateRecoversOnLateMountWithoutAnotherUpdate() async { + let stage = PaneHostStage() + let incoming = GhosttySurfaceView(frame: .zero) + let outgoing = GhosttySurfaceView(frame: .zero) + stage.attach(outgoing, to: stage.older, visible: true) + stage.attach(incoming, to: stage.newer, visible: true) + stage.older.removeFromSuperview() + stage.newer.removeFromSuperview() + + // Both hosts are between commits. The one-shot deferred pass runs + // before the destination mounts, so it cannot attach yet. + stage.attach(incoming, to: stage.older, visible: true) + await drainMainQueue(turns: pastBackstopBudget) + stage.window.contentView?.addSubview(stage.older) + await drainMainQueue(turns: pastBackstopBudget) + + XCTAssertTrue(incoming.superview === stage.older) + XCTAssertTrue(incoming.paneHostView === stage.older) + XCTAssertFalse(outgoing.superview === stage.older) + XCTAssertNil(incoming.pendingRecoveryHost) + } + + func testDeferredClaimCannotEvictReplacementInReusedCandidate() async { + let stage = PaneHostStage() + let incoming = GhosttySurfaceView(frame: .zero) + let replacement = GhosttySurfaceView(frame: .zero) + stage.attach(incoming, to: stage.newer, visible: true) + stage.newer.removeFromSuperview() + stage.attach(incoming, to: stage.older, visible: true) + stage.attach(replacement, to: stage.older, visible: true) + await drainMainQueue(turns: pastBackstopBudget) + + XCTAssertTrue(replacement.superview === stage.older) + XCTAssertTrue(stage.older.expectedSurface === replacement) + XCTAssertFalse(incoming.superview === stage.older) + } + /// P1 from the #109 review: the invalidation re-drive must never pin the /// surface into a candidate that has not mounted. A candidate still /// mid-commit claims when its own representable mounts (the recording is