Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Glint/Pane/PaneSurfaceRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
37 changes: 37 additions & 0 deletions GlintTests/PerformanceRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down