From 2eda49e46765539bed9ed758294c160e77a22e66 Mon Sep 17 00:00:00 2001 From: Vishal Date: Mon, 19 Oct 2026 18:31:22 -0500 Subject: [PATCH 1/5] fix(mobile): keep the iOS composer scroll position on refocus Restore the native editor offset after expansion and keep the caret visible following text and selection changes. Preserve manual scrolling on unchanged documents and typography rebuilds. Add focused UIKit regression checks. Co-Authored-By: Claude Opus 5.5 --- .../ios/T3ComposerEditorView.swift | 43 +++++++ .../tests/ios/ExpoModulesCore.swift | 18 +++ .../t3-composer-editor/tests/ios/main.swift | 120 ++++++++++++++++++ .../t3-composer-editor/tests/run-ios.sh | 17 +++ 4 files changed, 198 insertions(+) create mode 100644 apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift create mode 100644 apps/mobile/modules/t3-composer-editor/tests/ios/main.swift create mode 100755 apps/mobile/modules/t3-composer-editor/tests/run-ios.sh diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 9a5dae1db3f2..6b51f7597797 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -449,6 +449,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var tokensNeedRebuild = false private var chipsNeedMeasuredWidth = false private var hasDeferredForcedRebuild = false + private var pendingContentOffset: CGPoint? + private var needsSelectionReveal = false let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -583,11 +585,27 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public override func layoutSubviews() { super.layoutSubviews() + let previousSize = textView.bounds.size textView.frame = bounds if chipsNeedMeasuredWidth, bounds.width > 0 { chipsNeedMeasuredWidth = false applyControlledDocument(force: true) } + if textView.isScrollEnabled, bounds.width > 0, bounds.height > 0 { + textView.layoutIfNeeded() + if let offset = pendingContentOffset { + let minY = -textView.adjustedContentInset.top + let maxY = max(minY, textView.contentSize.height - bounds.height + textView.adjustedContentInset.bottom) + textView.setContentOffset(CGPoint(x: offset.x, y: min(maxY, max(minY, offset.y))), animated: false) + pendingContentOffset = nil + } + if needsSelectionReveal || (previousSize != bounds.size && textView.isFirstResponder) { + needsSelectionReveal = false + if let selection = textView.selectedTextRange { + textView.scrollRectToVisible(textView.caretRect(for: selection.end), animated: false) + } + } + } let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding let placeholderY = textView.textContainerInset.top let placeholderWidth = max( @@ -694,7 +712,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setScrollEnabled(_ scrollEnabled: Bool) { + guard textView.isScrollEnabled != scrollEnabled else { return } + if !scrollEnabled { + // Disabling scrolling discards UITextView's offset. Restore it after + // expansion has laid out the text, regardless of prop update order. + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } textView.isScrollEnabled = scrollEnabled + if scrollEnabled { + revealSelectionAfterLayout() + } } func setAutoFocus(_ autoFocus: Bool) { @@ -811,6 +838,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func textViewDidBeginEditing(_ textView: UITextView) { + revealSelectionAfterLayout() onComposerFocus() } @@ -836,6 +864,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro hasDeferredForcedRebuild = false let previousSelection = sourceSelection() + if textView.isScrollEnabled { + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } isApplyingControlledValue = true textView.attributedText = makeAttributedDocument() let targetSelection = requestedSelection ?? previousSelection @@ -843,6 +874,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.selectedRange = displayRange(for: targetSelection) restoreBaseTypingAttributes() isApplyingControlledValue = false + if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end { + revealSelectionAfterLayout() + } else { + setNeedsLayout() + } updatePlaceholderVisibility() emitContentSizeIfNeeded() } @@ -1053,6 +1089,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } value = textView.serializedText() + revealSelectionAfterLayout() let selection = sourceSelection() nativeEventCount += 1 onComposerChange([ @@ -1122,6 +1159,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false + revealSelectionAfterLayout() + } + + private func revealSelectionAfterLayout() { + needsSelectionReveal = true + setNeedsLayout() } private func updatePlaceholderVisibility() { diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift new file mode 100644 index 000000000000..9f56ae99bf97 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift @@ -0,0 +1,18 @@ +import UIKit + +// Only the Expo bridge is stubbed; the editor and text layout use production UIKit code. +public class AppContext {} + +open class ExpoView: UIView { + public required init(appContext: AppContext? = nil) { + super.init(frame: .zero) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { nil } +} + +public struct EventDispatcher { + public init() {} + public func callAsFunction(_ payload: [String: Any] = [:]) {} +} diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift new file mode 100644 index 000000000000..f1211f5ccc66 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift @@ -0,0 +1,120 @@ +import UIKit + +enum T3ComposerClipboard { + static func read() -> [String: String] { [:] } + static func write(text: String, fragment: String) {} +} + +var failures = 0 +var checks = 0 +func check(_ passed: Bool, _ message: String) { + checks += 1 + if !passed { + failures += 1 + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + } +} + +func document(_ composer: T3ComposerEditorView, _ value: String, caret: Int? = nil) { + let offset = caret ?? value.utf16.count + let payload: [String: Any] = [ + "value": value, + "selection": ["start": offset, "end": offset], + "tokensJson": "[]", + "mostRecentEventCount": 10_000, + "isNativeEcho": false, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + fatalError("Invalid controlled document fixture") + } + composer.setControlledDocumentJson(json) + composer.layoutIfNeeded() +} + +func caretIsVisible(_ editor: UITextView) -> Bool { + guard let selection = editor.selectedTextRange else { return false } + let caret = editor.caretRect(for: selection.end) + let visible = editor.bounds.contains(CGPoint(x: caret.midX, y: caret.midY)) + return visible +} + +let draft = (1...40).map { "Line \($0): a long composer draft" }.joined(separator: "\n") +for resizeBeforeProp in [false, true] { + let composer = T3ComposerEditorView() + composer.frame = CGRect(x: 0, y: 0, width: 320, height: 160) + composer.layoutIfNeeded() + guard let editor = composer.subviews.first as? UITextView else { + fatalError("Missing composer text view") + } + document(composer, draft, caret: (draft as NSString).range(of: "Line 22:").location) + editor.setContentOffset(CGPoint(x: 0, y: 320), animated: false) + let previousOffset = editor.contentOffset.y + + func collapse() { + if resizeBeforeProp { + composer.frame.size.height = 36 + composer.layoutIfNeeded() + } + composer.setScrollEnabled(false) + composer.setContentInsetVertical(6) + composer.frame.size.height = 36 + composer.layoutIfNeeded() + composer.setScrollEnabled(false) + } + + func expand() { + if resizeBeforeProp { + composer.frame.size.height = 160 + composer.layoutIfNeeded() + } + composer.setScrollEnabled(true) + composer.setContentInsetVertical(0) + composer.frame.size.height = 160 + composer.layoutIfNeeded() + } + + collapse() + expand() + check(abs(editor.contentOffset.y - previousOffset) < 1, "Restore offset after collapse, resize first: \(resizeBeforeProp), expected \(previousOffset), got \(editor.contentOffset.y)") + check(caretIsVisible(editor), "Reveal caret after expansion") + + document(composer, draft) + check(caretIsVisible(editor), "Reveal the caret at the end of a long draft") + let longerDraft = draft + "\nNew dictated text at the end" + document(composer, longerDraft) + check(caretIsVisible(editor), "Reveal caret after controlled insertion") + check(editor.selectedRange.location == longerDraft.utf16.count, "Keep the requested selection") + + editor.setContentOffset(CGPoint(x: 0, y: 200), animated: false) + composer.setFontFamily("Helvetica") + composer.layoutIfNeeded() + check(abs(editor.contentOffset.y - 200) < 1, "Preserve manual scroll during a typography rebuild") + document(composer, longerDraft) + check(abs(editor.contentOffset.y - 200) < 1, "An unchanged controlled document must not reveal the caret") + + composer.setSelection(start: 0, end: 0) + composer.layoutIfNeeded() + check(caretIsVisible(editor), "Reveal an explicitly moved caret") + composer.setSelection(start: longerDraft.utf16.count, end: longerDraft.utf16.count) + composer.layoutIfNeeded() + check(caretIsVisible(editor), "Reveal a caret moved to the end") + + editor.insertText(" typed text") + composer.layoutIfNeeded() + check(caretIsVisible(editor), "Reveal caret after native insertion") + + collapse() + document(composer, longerDraft + String(repeating: "\nMore dictated text", count: 10)) + expand() + check(caretIsVisible(editor), "Reveal text inserted while collapsed after expansion") + + collapse() + document(composer, "") + expand() + check(abs(editor.contentOffset.y) < 1, "Clamp the saved offset when the draft is cleared") + check(editor.text.isEmpty, "Keep the cleared draft empty") +} + +print("\(checks - failures)/\(checks) composer scroll checks passed") +exit(failures == 0 ? 0 : 1) diff --git a/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh b/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh new file mode 100755 index 000000000000..f62f27da7757 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +module_dir=$(cd "$(dirname "$0")/.." && pwd) +sdk=$(xcrun --sdk macosx --show-sdk-path) +output=$(mktemp -d) +trap 'rm -rf "$output"' EXIT +compiler=$(xcrun --find swiftc) +flags=(-target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" + -F "$sdk/System/iOSSupport/System/Library/Frameworks") +"$compiler" "${flags[@]}" -emit-library -emit-module -module-name ExpoModulesCore \ + "$module_dir/tests/ios/ExpoModulesCore.swift" \ + -emit-module-path "$output/ExpoModulesCore.swiftmodule" -o "$output/libExpoModulesCore.dylib" +"$compiler" "${flags[@]}" -I "$output" -L "$output" -lExpoModulesCore \ + -Xlinker -rpath -Xlinker "$output" \ + "${1:-$module_dir/ios/T3ComposerEditorView.swift}" "$module_dir/tests/ios/main.swift" \ + -o "$output/composer-tests" +"$output/composer-tests" From 23481f092959f60e508f790210ed49208580f092 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 21 Oct 2026 20:48:57 +0000 Subject: [PATCH 2/5] fix(mobile): tapping the collapsed iOS composer returns to your place in the draft The collapsed composer shows only the draft's first line, so the tap that reopens it put the caret on that line and the editor opened at the top of a long draft. That tap now keeps the caret where the draft was left. Drafts that fit the collapsed row still place the caret where you tap. Co-authored-by: Vishal Doshi Co-Authored-By: Claude Opus 5.5 --- .../ios/T3ComposerEditorView.swift | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 6b51f7597797..bbd721b23c6e 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -451,6 +451,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var hasDeferredForcedRebuild = false private var pendingContentOffset: CGPoint? private var needsSelectionReveal = false + private var selectionBeforeFocusingTap: NSRange? let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -522,6 +523,13 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro @objc private func openContext(_ recognizer: UITapGestureRecognizer) { guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { textView.becomeFirstResponder() + if let selection = selectionBeforeFocusingTap { + selectionBeforeFocusingTap = nil + // UIKit places the caret from its own tap recognizer during this same touch. + DispatchQueue.main.async { [weak self] in + self?.restoreSelection(selection) + } + } return } openContext(index: index, attachment: attachment) @@ -560,7 +568,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - true + // The collapsed editor only shows the draft's first line, so a tap that expands an + // overflowing draft returns to the caret it was left at instead of moving it there. + let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds() + selectionBeforeFocusingTap = collapsedOverflow ? textView.selectedRange : nil + return true } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -1162,6 +1174,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro revealSelectionAfterLayout() } + private func restoreSelection(_ selection: NSRange) { + guard textView.isFirstResponder, + !NSEqualRanges(selection, textView.selectedRange), + NSMaxRange(selection) <= textView.attributedText.length else { + return + } + textView.selectedRange = selection + revealSelectionAfterLayout() + } + + private func draftOverflowsBounds() -> Bool { + let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) + return size.height > textView.bounds.height + 1 + } + private func revealSelectionAfterLayout() { needsSelectionReveal = true setNeedsLayout() From 178e7e66bc3a183cf6c9a596cee8ce141452e06c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 21 Oct 2026 21:26:24 +0000 Subject: [PATCH 3/5] fix(mobile): skip the tap caret restore when the draft changed during the touch The caret saved at touch-down is put back after the tap only if the draft is still the same and no text is being composed, so a transcript that lands mid-tap keeps its caret. The saved caret is in source offsets, so chips rebuilt in between still map correctly. The native-insertion check now types past the visible lines. Co-Authored-By: Claude Opus 5.5 --- .../ios/T3ComposerEditorView.swift | 21 +++++++++++-------- .../t3-composer-editor/tests/ios/main.swift | 6 ++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index bbd721b23c6e..81f688b9eb22 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -451,7 +451,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var hasDeferredForcedRebuild = false private var pendingContentOffset: CGPoint? private var needsSelectionReveal = false - private var selectionBeforeFocusingTap: NSRange? + private var selectionBeforeFocusingTap: (selection: ComposerSelectionPayload, value: String)? let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -523,11 +523,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro @objc private func openContext(_ recognizer: UITapGestureRecognizer) { guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { textView.becomeFirstResponder() - if let selection = selectionBeforeFocusingTap { + if let snapshot = selectionBeforeFocusingTap { selectionBeforeFocusingTap = nil // UIKit places the caret from its own tap recognizer during this same touch. DispatchQueue.main.async { [weak self] in - self?.restoreSelection(selection) + self?.restoreSelection(snapshot.selection, ifValueIs: snapshot.value) } } return @@ -571,7 +571,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro // The collapsed editor only shows the draft's first line, so a tap that expands an // overflowing draft returns to the caret it was left at instead of moving it there. let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds() - selectionBeforeFocusingTap = collapsedOverflow ? textView.selectedRange : nil + selectionBeforeFocusingTap = collapsedOverflow ? (sourceSelection(), value) : nil return true } @@ -1174,13 +1174,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro revealSelectionAfterLayout() } - private func restoreSelection(_ selection: NSRange) { - guard textView.isFirstResponder, - !NSEqualRanges(selection, textView.selectedRange), - NSMaxRange(selection) <= textView.attributedText.length else { + private func restoreSelection(_ selection: ComposerSelectionPayload, ifValueIs expectedValue: String) { + // A draft that changed during the touch, such as a transcript landing, owns the caret now. + guard textView.isFirstResponder, textView.markedTextRange == nil, value == expectedValue else { return } - textView.selectedRange = selection + let range = displayRange(for: selection) + guard !NSEqualRanges(range, textView.selectedRange) else { + return + } + textView.selectedRange = range revealSelectionAfterLayout() } diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift index f1211f5ccc66..ace32c960581 100644 --- a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift +++ b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift @@ -100,9 +100,11 @@ for resizeBeforeProp in [false, true] { composer.layoutIfNeeded() check(caretIsVisible(editor), "Reveal a caret moved to the end") - editor.insertText(" typed text") + let typedLines = String(repeating: "\nTyped line", count: 12) + editor.insertText(typedLines) composer.layoutIfNeeded() - check(caretIsVisible(editor), "Reveal caret after native insertion") + check(editor.text.hasSuffix(typedLines), "Insert typed text at the caret") + check(caretIsVisible(editor), "Reveal caret after native insertion past the visible lines") collapse() document(composer, longerDraft + String(repeating: "\nMore dictated text", count: 10)) From 3e1a7d35d035f1cb7ee9637653305b048c91c221 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 21 Oct 2026 21:40:25 +0000 Subject: [PATCH 4/5] fix(mobile): a selection set from JS during the tap cancels its caret restore The tap restore waits for the next main-queue turn, and a controlled document or setSelection that moves the caret in that window keeps the draft text the same, so it slipped past the draft check. Those calls now cancel the pending restore when they move the selection. Co-Authored-By: Claude Opus 5.5 --- .../ios/T3ComposerEditorView.swift | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 81f688b9eb22..24f653fb7d46 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -13,7 +13,7 @@ private struct ComposerTokenPayload: Decodable { let end: Int } -private struct ComposerSelectionPayload: Decodable { +private struct ComposerSelectionPayload: Decodable, Equatable { let start: Int let end: Int } @@ -523,11 +523,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro @objc private func openContext(_ recognizer: UITapGestureRecognizer) { guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { textView.becomeFirstResponder() - if let snapshot = selectionBeforeFocusingTap { - selectionBeforeFocusingTap = nil + if selectionBeforeFocusingTap != nil { // UIKit places the caret from its own tap recognizer during this same touch. DispatchQueue.main.async { [weak self] in - self?.restoreSelection(snapshot.selection, ifValueIs: snapshot.value) + self?.restoreSelectionBeforeFocusingTap() } } return @@ -662,10 +661,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] tokensNeedRebuild = true } + let previousSelection = sourceSelection() value = document.value requestedSelection = document.selection applyControlledDocument(force: tokensNeedRebuild) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) if tokensMatchCurrentValue() { tokensNeedRebuild = false } @@ -777,8 +778,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setSelection(start: Int, end: Int) { + let previousSelection = sourceSelection() requestedSelection = ComposerSelectionPayload(start: start, end: end) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) } public func textViewDidChange(_ textView: UITextView) { @@ -1174,12 +1177,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro revealSelectionAfterLayout() } - private func restoreSelection(_ selection: ComposerSelectionPayload, ifValueIs expectedValue: String) { + private func restoreSelectionBeforeFocusingTap() { + guard let snapshot = selectionBeforeFocusingTap else { + return + } + selectionBeforeFocusingTap = nil // A draft that changed during the touch, such as a transcript landing, owns the caret now. - guard textView.isFirstResponder, textView.markedTextRange == nil, value == expectedValue else { + guard textView.isFirstResponder, textView.markedTextRange == nil, value == snapshot.value else { return } - let range = displayRange(for: selection) + let range = displayRange(for: snapshot.selection) guard !NSEqualRanges(range, textView.selectedRange) else { return } @@ -1187,6 +1194,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro revealSelectionAfterLayout() } + private func cancelFocusingTapRestoreIfSelectionMoved(from previousSelection: ComposerSelectionPayload) { + if sourceSelection() != previousSelection { + selectionBeforeFocusingTap = nil + } + } + private func draftOverflowsBounds() -> Bool { let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) return size.height > textView.bounds.height + 1 From 90903b292fe83f489b608a78c3555963ace825c2 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 03:12:09 +0000 Subject: [PATCH 5/5] test(mobile): drop the composer UIKit harness CI never runs Co-Authored-By: Claude Opus 5.5 --- .../tests/ios/ExpoModulesCore.swift | 18 --- .../t3-composer-editor/tests/ios/main.swift | 122 ------------------ .../t3-composer-editor/tests/run-ios.sh | 17 --- 3 files changed, 157 deletions(-) delete mode 100644 apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift delete mode 100644 apps/mobile/modules/t3-composer-editor/tests/ios/main.swift delete mode 100755 apps/mobile/modules/t3-composer-editor/tests/run-ios.sh diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift deleted file mode 100644 index 9f56ae99bf97..000000000000 --- a/apps/mobile/modules/t3-composer-editor/tests/ios/ExpoModulesCore.swift +++ /dev/null @@ -1,18 +0,0 @@ -import UIKit - -// Only the Expo bridge is stubbed; the editor and text layout use production UIKit code. -public class AppContext {} - -open class ExpoView: UIView { - public required init(appContext: AppContext? = nil) { - super.init(frame: .zero) - } - - @available(*, unavailable) - public required init?(coder: NSCoder) { nil } -} - -public struct EventDispatcher { - public init() {} - public func callAsFunction(_ payload: [String: Any] = [:]) {} -} diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift deleted file mode 100644 index ace32c960581..000000000000 --- a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift +++ /dev/null @@ -1,122 +0,0 @@ -import UIKit - -enum T3ComposerClipboard { - static func read() -> [String: String] { [:] } - static func write(text: String, fragment: String) {} -} - -var failures = 0 -var checks = 0 -func check(_ passed: Bool, _ message: String) { - checks += 1 - if !passed { - failures += 1 - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - } -} - -func document(_ composer: T3ComposerEditorView, _ value: String, caret: Int? = nil) { - let offset = caret ?? value.utf16.count - let payload: [String: Any] = [ - "value": value, - "selection": ["start": offset, "end": offset], - "tokensJson": "[]", - "mostRecentEventCount": 10_000, - "isNativeEcho": false, - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let json = String(data: data, encoding: .utf8) else { - fatalError("Invalid controlled document fixture") - } - composer.setControlledDocumentJson(json) - composer.layoutIfNeeded() -} - -func caretIsVisible(_ editor: UITextView) -> Bool { - guard let selection = editor.selectedTextRange else { return false } - let caret = editor.caretRect(for: selection.end) - let visible = editor.bounds.contains(CGPoint(x: caret.midX, y: caret.midY)) - return visible -} - -let draft = (1...40).map { "Line \($0): a long composer draft" }.joined(separator: "\n") -for resizeBeforeProp in [false, true] { - let composer = T3ComposerEditorView() - composer.frame = CGRect(x: 0, y: 0, width: 320, height: 160) - composer.layoutIfNeeded() - guard let editor = composer.subviews.first as? UITextView else { - fatalError("Missing composer text view") - } - document(composer, draft, caret: (draft as NSString).range(of: "Line 22:").location) - editor.setContentOffset(CGPoint(x: 0, y: 320), animated: false) - let previousOffset = editor.contentOffset.y - - func collapse() { - if resizeBeforeProp { - composer.frame.size.height = 36 - composer.layoutIfNeeded() - } - composer.setScrollEnabled(false) - composer.setContentInsetVertical(6) - composer.frame.size.height = 36 - composer.layoutIfNeeded() - composer.setScrollEnabled(false) - } - - func expand() { - if resizeBeforeProp { - composer.frame.size.height = 160 - composer.layoutIfNeeded() - } - composer.setScrollEnabled(true) - composer.setContentInsetVertical(0) - composer.frame.size.height = 160 - composer.layoutIfNeeded() - } - - collapse() - expand() - check(abs(editor.contentOffset.y - previousOffset) < 1, "Restore offset after collapse, resize first: \(resizeBeforeProp), expected \(previousOffset), got \(editor.contentOffset.y)") - check(caretIsVisible(editor), "Reveal caret after expansion") - - document(composer, draft) - check(caretIsVisible(editor), "Reveal the caret at the end of a long draft") - let longerDraft = draft + "\nNew dictated text at the end" - document(composer, longerDraft) - check(caretIsVisible(editor), "Reveal caret after controlled insertion") - check(editor.selectedRange.location == longerDraft.utf16.count, "Keep the requested selection") - - editor.setContentOffset(CGPoint(x: 0, y: 200), animated: false) - composer.setFontFamily("Helvetica") - composer.layoutIfNeeded() - check(abs(editor.contentOffset.y - 200) < 1, "Preserve manual scroll during a typography rebuild") - document(composer, longerDraft) - check(abs(editor.contentOffset.y - 200) < 1, "An unchanged controlled document must not reveal the caret") - - composer.setSelection(start: 0, end: 0) - composer.layoutIfNeeded() - check(caretIsVisible(editor), "Reveal an explicitly moved caret") - composer.setSelection(start: longerDraft.utf16.count, end: longerDraft.utf16.count) - composer.layoutIfNeeded() - check(caretIsVisible(editor), "Reveal a caret moved to the end") - - let typedLines = String(repeating: "\nTyped line", count: 12) - editor.insertText(typedLines) - composer.layoutIfNeeded() - check(editor.text.hasSuffix(typedLines), "Insert typed text at the caret") - check(caretIsVisible(editor), "Reveal caret after native insertion past the visible lines") - - collapse() - document(composer, longerDraft + String(repeating: "\nMore dictated text", count: 10)) - expand() - check(caretIsVisible(editor), "Reveal text inserted while collapsed after expansion") - - collapse() - document(composer, "") - expand() - check(abs(editor.contentOffset.y) < 1, "Clamp the saved offset when the draft is cleared") - check(editor.text.isEmpty, "Keep the cleared draft empty") -} - -print("\(checks - failures)/\(checks) composer scroll checks passed") -exit(failures == 0 ? 0 : 1) diff --git a/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh b/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh deleted file mode 100755 index f62f27da7757..000000000000 --- a/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -set -euo pipefail -module_dir=$(cd "$(dirname "$0")/.." && pwd) -sdk=$(xcrun --sdk macosx --show-sdk-path) -output=$(mktemp -d) -trap 'rm -rf "$output"' EXIT -compiler=$(xcrun --find swiftc) -flags=(-target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" - -F "$sdk/System/iOSSupport/System/Library/Frameworks") -"$compiler" "${flags[@]}" -emit-library -emit-module -module-name ExpoModulesCore \ - "$module_dir/tests/ios/ExpoModulesCore.swift" \ - -emit-module-path "$output/ExpoModulesCore.swiftmodule" -o "$output/libExpoModulesCore.dylib" -"$compiler" "${flags[@]}" -I "$output" -L "$output" -lExpoModulesCore \ - -Xlinker -rpath -Xlinker "$output" \ - "${1:-$module_dir/ios/T3ComposerEditorView.swift}" "$module_dir/tests/ios/main.swift" \ - -o "$output/composer-tests" -"$output/composer-tests"