iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: defer comment sheet window cleanup to avoid dismiss crash

Hiding the dedicated comment window (or calling endEditing on it)
synchronously inside SwiftUI's .sheet onDismiss crashes when the
TextField is still first responder — resigning a @FocusState-bound
field mid-sheet-teardown re-enters SwiftUI while the sheet view tree
is still unwinding. Defer both endEditing and isHidden to the next
main-runloop tick so the current dismiss callback returns first.

Also drops the redundant dismiss() in CommentSheetContent's Done
button. onDismiss() already clears the sheet target, so dismiss()
was a double-dismiss racing with the same teardown.

Adds signposts and pendingDismissSource tracking around the second
window path to make any future regression easier to bisect.

+36 -8
+36 -7
Grain/ViewModels/StoryCommentPresenter.swift
··· 29 29 private final class CommentSheetWindowState { 30 30 var target: CommentSheetTarget? 31 31 @ObservationIgnored var onDismissed: (() -> Void)? 32 + /// Tracks which code path first cleared `target` so the dismiss log can 33 + /// distinguish swipe-down (default) from dim-tap, Done-button, and 34 + /// `close()`. Cleared inside `.sheet` `onDismiss`. 35 + @ObservationIgnored var pendingDismissSource: String? 32 36 } 33 37 34 38 // MARK: - Root view of the dedicated window ··· 51 55 .ignoresSafeArea() 52 56 .contentShape(Rectangle()) 53 57 .onTapGesture { 54 - spLogger.info("[dim.tap] dismissing via background tap") 55 58 spSignposter.emitEvent("dim.tap") 59 + state.pendingDismissSource = "dim.tap" 56 60 state.target = nil 57 61 } 58 62 .transition(.opacity) ··· 64 68 // in `onDismiss` truncates it and the user sees a pop. 65 69 .animation(.easeInOut(duration: 0.35), value: state.target != nil) 66 70 .sheet(item: $state.target, onDismiss: { 67 - spLogger.info("[sheet.onDismiss] fired") 68 - spSignposter.emitEvent("sheet.onDismiss") 71 + let source = state.pendingDismissSource ?? "swipe" 72 + state.pendingDismissSource = nil 73 + spLogger.info("[sheet.onDismiss] source=\(source)") 74 + spSignposter.emitEvent("sheet.onDismiss", "source=\(source)") 69 75 let cb = state.onDismissed 70 76 state.onDismissed = nil 71 77 cb?() ··· 77 83 focusInput: target.focusInput, 78 84 onProfileTap: target.onProfileTap, 79 85 onDismiss: { 80 - spLogger.info("[sheet.content] onDismiss closure called") 86 + state.pendingDismissSource = "content.onDismiss" 81 87 state.target = nil 82 88 } 83 89 ) ··· 142 148 spSignposter.emitEvent("open.skipped", "reason=target-set") 143 149 return 144 150 } 151 + guard state.onDismissed == nil else { 152 + spLogger.info("[open] SKIPPED — dismiss in progress") 153 + spSignposter.emitEvent("open.skipped", "reason=dismiss-in-progress") 154 + return 155 + } 145 156 guard let auth = authManager, 146 157 let statusCache = storyStatusCache, 147 158 let viewed = viewedStories ··· 158 169 159 170 let intervalState = spSignposter.beginInterval("open", "focusInput=\(focusInput)") 160 171 openSignpostState = intervalState 161 - spLogger.info("[open] begin storyUri=\(storyUri) focusInput=\(focusInput)") 172 + spLogger.info("[open] storyUri=\(storyUri) focusInput=\(focusInput)") 162 173 163 174 // Lazy window creation — built once per app session and reused. The 164 175 // root view's state/bindings persist across open/close cycles. ··· 179 190 // onDismiss closure sees the correct handler. 180 191 state.onDismissed = { [weak self] in 181 192 guard let self else { return } 182 - spLogger.info("[onDismissed] running") 193 + spLogger.info("[onDismissed] begin") 194 + spSignposter.emitEvent("onDismissed.begin") 195 + 183 196 presentedStoryUri = nil 184 - commentWindow?.isHidden = true 197 + 198 + // Hiding the comment window (or calling endEditing on it) crashes 199 + // when done synchronously inside SwiftUI's .sheet onDismiss: 200 + // resigning the @FocusState-bound TextField mid-sheet-teardown 201 + // re-enters SwiftUI while the sheet view tree is still unwinding. 202 + // Defer both to the next main-runloop tick so the current dismiss 203 + // callback returns first. Do NOT inline these — the crash is 204 + // easy to re-introduce. 205 + let windowToHide = commentWindow 206 + DispatchQueue.main.async { 207 + windowToHide?.endEditing(true) 208 + windowToHide?.isHidden = true 209 + spSignposter.emitEvent("onDismissed.window-hidden") 210 + } 211 + 185 212 if let intervalState = openSignpostState { 186 213 spSignposter.endInterval("open", intervalState) 187 214 openSignpostState = nil 188 215 } 189 216 onDidClose?() 217 + spSignposter.emitEvent("onDismissed.end") 190 218 } 191 219 192 220 state.target = CommentSheetTarget( ··· 203 231 func close() { 204 232 spLogger.info("[close] programmatic close") 205 233 spSignposter.emitEvent("close.programmatic") 234 + state.pendingDismissSource = "close.programmatic" 206 235 state.target = nil 207 236 } 208 237
-1
Grain/Views/Comments/CommentSheetContent.swift
··· 68 68 ToolbarItem(placement: .cancellationAction) { 69 69 Button("Done") { 70 70 onDismiss() 71 - dismiss() 72 71 } 73 72 } 74 73 }