ReviewOS

also looking at this

craft-native/craft

fix(mobile): preserve native editing, navigation, and durable media requests

#369
Merged chris wants to merge codex/native-vertical-scroll-tab-swipes into main
36 files +864 -185

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/fixtures/native-navigation/Home.stxmodified+1-1
Changes to packages/ios/fixtures/native-navigation/Home.stx
@@ -570,7 +570,7 @@ function openDetailsThenUpdateBackground() {
570570 <View :for="row in navigationRows" :key="row.id" style={{"height":52,"flexDirection":"row","gap":4}}>
571571 <Text :text="row.label" style={{"width":100}} />
572572 <TextInput :testID="'navigation-input-' + row.id" placeholder="Draft" style={{"width":100}} @input="updateNavigationDraft(row.id, $event)" />
573 <Button @click="selectNavigationRow(row.id)">Select row</Button>
573 <Button :testID="'navigation-select-' + row.id" @click="selectNavigationRow(row.id)">Select row</Button>
574574 </View>
575575 <Button testID="keyed-list-reorder" @click="reorderNavigationRows()">Reorder rows</Button>
576576 <Text testID="navigation-home-state" :text="'Home state: ' + navigationName + '/' + navigationCount" />
packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+19-4
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -158,6 +158,21 @@ final class NativeNavigationUITests: XCTestCase {
158158 XCTAssertEqual(home.frame.minX, baseline.minX, accuracy: 1)
159159 }
160160
161 func testDraggingFromAButtonScrollsWithoutActivatingIt() {
162 let app = XCUIApplication()
163 app.launch()
164 let scroll = app.scrollViews["native-root-scroll"]
165 let button = app.buttons["toggle-safe-area"]
166 let marker = app.staticTexts["home-title"]
167 XCTAssertTrue(scroll.waitForExistence(timeout: 10))
168 XCTAssertTrue(button.waitForExistence(timeout: 10))
169 let baseline = marker.frame.minY
170 let start = button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
171 start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -120)), withVelocity: .slow, thenHoldForDuration: 0.3)
172 XCTAssertLessThan(marker.frame.minY, baseline - 30, "A drag that starts on a button must scroll the list")
173 XCTAssertEqual(app.staticTexts["safe-area-status"].label, "Safe edges: top,bottom", "Scrolling must cancel the button's tap")
174 }
175
161176 func testMultilineInputKeepsKeyboardFocusWhenTapped() {
162177 let app = XCUIApplication()
163178 app.launch()
@@ -463,7 +478,7 @@ final class NativeNavigationUITests: XCTestCase {
463478 XCTAssertEqual(beaInput.value as? String, "draft")
464479 beaInput.typeText("!")
465480 XCTAssertEqual(beaInput.value as? String, "draft!")
466 app.buttons.matching(NSPredicate(format: "label == %@", "Select row")).firstMatch.tap()
481 app.buttons["navigation-select-b"].tap()
467482 XCTAssertTrue(waitForLabel(status, "Selected: b"))
468483 XCTAssertTrue(scroll.exists, "keyed reconciliation replaced the list scroll host")
469484 }
@@ -544,7 +559,7 @@ final class NativeNavigationUITests: XCTestCase {
544559 let indeterminateCheckbox = app.buttons["native-checkbox-indeterminate"]
545560 XCTAssertTrue(indeterminateCheckbox.waitForExistence(timeout: 5))
546561 XCTAssertEqual(indeterminateCheckbox.value as? String, "mixed")
547 XCTAssertEqual(app.buttons["checkbox-checked-precedence"].value as? String, "0")
562 XCTAssertEqual(app.buttons["checkbox-checked-precedence"].value as? String, "unchecked")
548563 XCTAssertTrue(app.buttons["disabled-button"].waitForExistence(timeout: 5))
549564 XCTAssertFalse(app.buttons["disabled-button"].isEnabled)
550565 XCTAssertEqual(image.label, "Native pixel")
@@ -744,7 +759,7 @@ final class NativeNavigationUITests: XCTestCase {
744759 XCTAssertTrue(panel.isEnabled, "clearing accessibilityState.disabled should restore a generic pressable")
745760 panel.tap()
746761 XCTAssertTrue(app.staticTexts["panel pressed"].waitForExistence(timeout: 5))
747 let pressable = app.otherElements["native-pressable"]
762 let pressable = app.buttons["native-pressable"]
748763 reveal(pressable, in: rootScroll)
749764 // Earlier reveal drags may start on the pressable and legitimately
750765 // deliver a cancelled press-in/out pair. Assert this tap's delta.
@@ -757,7 +772,7 @@ final class NativeNavigationUITests: XCTestCase {
757772 pressable.tap()
758773 XCTAssertTrue(app.staticTexts["pressable pressed"].waitForExistence(timeout: 5))
759774 XCTAssertEqual(lifecycle.label, "Press lifecycle: \(pressesIn + 1)/\(pressesOut + 1)")
760 let disabledPressable = app.otherElements["disabled-pressable"]
775 let disabledPressable = app.buttons["disabled-pressable"]
761776 XCTAssertTrue(disabledPressable.waitForExistence(timeout: 5))
762777 XCTAssertFalse(disabledPressable.isEnabled)
763778 let longPressPanel = app.otherElements["long-press-panel"]
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+36-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -1915,6 +1915,42 @@ final class NativeRenderUnitTests: XCTestCase {
19151915 XCTAssertEqual(proof.font?.pointSize, 22)
19161916 }
19171917
1918 func testRetainedRowButtonStillDispatchesAfterItsPreviousSiblingIsRemoved() throws {
1919 let controller = CraftNativeScreenController(config: CraftConfig())
1920 controller.loadViewIfNeeded()
1921 controller.evaluateScript("globalThis.__rowPress = ''; globalThis.__stxNativeCallback = function(json) { const message = JSON.parse(json); if (message.type === 'EVENT') globalThis.__rowPress = message.payload.handlerName; };")
1922 func row(_ id: String) -> [String: Any] {
1923 ["id": "row-\(id)", "type": "View", "props": ["key": id], "children": [
1924 ["id": "input-\(id)", "type": "TextInput", "props": ["key": "input-\(id)", "defaultValue": "Draft"]],
1925 ["id": "button-\(id)", "type": "Button", "props": ["key": "button-\(id)", "title": "Select row"], "events": ["onPress": "select-\(id)"]],
1926 ]]
1927 }
1928 controller.render(document([row("a"), row("b")]))
1929 let button = try XCTUnwrap(find(UIButton.self, key: "button-b", below: controller.view))
1930 controller.render(document([row("b"), row("c")]))
1931 XCTAssertTrue(button === find(UIButton.self, key: "button-b", below: controller.view))
1932 button.sendActions(for: .touchUpInside)
1933 XCTAssertEqual(controller.evaluateScript("__rowPress")?.toString(), "select-b")
1934 }
1935
1936 func testScrollCanCancelButtonTrackingWithoutTakingSliderOrInputTracking() throws {
1937 let controller = CraftNativeScreenController(config: CraftConfig())
1938 controller.loadViewIfNeeded()
1939 controller.render(document([[
1940 "type": "ScrollView", "props": ["key": "form"], "style": ["height": 300], "children": [
1941 ["type": "Button", "props": ["key": "action", "title": "Save"]],
1942 ["type": "Slider", "props": ["key": "position", "value": 0.5]],
1943 ["type": "Switch", "props": ["key": "toggle", "value": false]],
1944 ["type": "TextInput", "props": ["key": "draft", "value": "Draft"]],
1945 ],
1946 ]]))
1947 let form = try XCTUnwrap(find(UIScrollView.self, key: "form", below: controller.view))
1948 XCTAssertTrue(form.touchesShouldCancel(in: try XCTUnwrap(find(UIButton.self, key: "action", below: controller.view))))
1949 XCTAssertFalse(form.touchesShouldCancel(in: try XCTUnwrap(find(UISlider.self, key: "position", below: controller.view))))
1950 XCTAssertFalse(form.touchesShouldCancel(in: try XCTUnwrap(find(UISwitch.self, key: "toggle", below: controller.view))))
1951 XCTAssertFalse(form.touchesShouldCancel(in: try XCTUnwrap(find(UITextField.self, key: "draft", below: controller.view))))
1952 }
1953
19181954 func testScrollKeyboardTapPreservesTextAreaFocusAndSelectionButDismissesBackground() throws {
19191955 let controller = CraftNativeScreenController(config: CraftConfig())
19201956 let window = try hostWindow()
packages/ios/templates/CraftNativeScreen.swiftmodified+4-0
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -4010,6 +4010,10 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
40104010 required init?(coder: NSCoder) { nil }
40114011
40124012 override func touchesShouldCancel(in view: UIView) -> Bool {
4013 // A button tracks its tap immediately, but a drag that starts there
4014 // still belongs to the list. Keep direct manipulation controls (a
4015 // slider, switch or text field) on their native tracking path.
4016 if view is UIButton { return true }
40134017 if view is UIControl { return false }
40144018 return super.touchesShouldCancel(in: view)
40154019 }
scripts/native-renderer-contract.test.tsmodified+1-1
Changes to scripts/native-renderer-contract.test.ts
@@ -263,7 +263,7 @@ describe('native renderer component contract', () => {
263263 expect(android).toContain('checkboxChecked is Boolean -> checkboxChecked')
264264 expect(navigationHome).toContain('<Checkbox testID="checkbox-checked-precedence" :checked="false" :value="true" />')
265265 expect(androidNavigationTest).toContain('checked should take precedence over value')
266 expect(iosNavigationTest).toContain('app.buttons["checkbox-checked-precedence"].value as? String, "0"')
266 expect(iosNavigationTest).toContain('app.buttons["checkbox-checked-precedence"].value as? String, "unchecked"')
267267 expect(guide).toContain('controlled values are present,')
268268 expect(guide).toContain('`checked` takes precedence. It delivers')
269269 })