Skip to content

Fix iOS 27 reader defects and speed up CI - #851

Merged
onevcat merged 11 commits into
mainfrom
fix/ios-mirror-ios27-ui
Oct 4, 2026
Merged

onevcat merged 11 commits into
mainfrom
fix/ios-mirror-ios27-ui

Conversation

@onevcat

@onevcat onevcat commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes two iOS 27 defects in the iOS mirror reader, and makes CI faster and cheaper: the mirror clients run only when they change, CI runs no iOS UI tests, and the Mac app job gets a working compilation cache fallback.

iOS 27: "Load Earlier 200 Lines" does not respond

On iOS 27, a Button in the same stack as .textSelection(.enabled) text rows gets no taps. The history view put the button as the first child of the LazyVStack that holds the selectable lines (a plain VStack also fails; removing the text selection makes it work). The button now sits outside the stack of selectable rows. Text selection is unchanged.

iOS 27: live reading position moves after a pane switch

The reading view restored an absolute ScrollPosition(y:), but LazyVStack estimates the heights of rows above the visible area that it has not laid out. On iOS 27 the same offset then shows different text (17–31 pt off; exact on iOS 26.5).

The live reader now stores an anchor: a document row and the distance from the top of that row to the top of the visible area.

  • MirrorDocumentView is one flat LazyVStack of rows: a code or table block, a short text block, or one 32-line / 4096-scalar chunk of a long text block. Each row reports its top in the scroll content space.
  • On appear, the reader scrolls the anchor row to the top, then applies the offset in the row when the row reports its laid-out top. If the row no longer exists, it goes to the bottom.
  • MirrorSession.liveReadingAnchor replaces liveReadingOffset; it is @ObservationIgnored because the reader writes it on every scroll.
  • Rendering stays lazy. The 10,000-line first-viewport performance test is not slower (0.323 s against 0.349 s on main).

The history view still restores an absolute offset (single-line rows, so the estimates are exact for the fixture).

iOS mirror tests

  • iPhone-only layout: the only phone-specific UI test checked the userInterfaceIdiom == .phone branch of the initial split-view layout, which cannot run on iPad. That decision is now ContentView.initialLayout(for:) with a unit test, and the UI test is removed. The other "iPhone" UI tests pass on iPad, so all UI tests run on one iPad simulator.
  • testRemoteScrollShowsLoadingAndKeepsHistoryIndependent was flaky: the fixture Host confirmed the scroll after 2 s (and the app gives up after 5 s), and on a slow runner each XCUITest query took about 3 s. The fixture now holds each scroll until the test posts a Darwin notification (--mirror-ui-scroll-hold-fixture), and its confirmation timeout is longer. MirrorSession takes the timeout as a parameter (default 5 s).
  • make test-mirror-ios runs the unit and UI tests on iPad; make test-mirror-ios-unit runs only the unit tests. Both print a timestamped heartbeat and keep the raw log next to the result bundle.
  • select_ios_simulator.py works with the Python 3.9 that macOS includes.
  • Two compiler warnings are fixed (resignFirstResponder() result; a main-actor UIKey init in a test UIPress override).

CI

Workflow Runs
test (Mac app) Every change except changes only to Mirror/iOS/, Mirror/Android/ or their workflows
mirror-ios Changes to Mirror/iOS/, Mirror/Shared/ or its workflow, and each published release. Unit tests only
mirror-android Changes to Mirror/Android/ or its workflow, and each published release

The UI suite takes minutes on a hosted runner (each test launches the app; the runner is 3–5x slower than a Mac mini), so it runs locally with make test-mirror-ios.

Mac app job:

  • The Xcode compilation cache (CAS) step cached ~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex. Because the build uses -derivedDataPath, Xcode keeps the CAS in build/ci-derived-data, so that path never existed and the step never restored or saved anything. Each miss of the strict App incremental cache (changed Makefile, workflow, file roster, …) therefore compiled everything without a cache: about 10 minutes in the previous run of this PR. The step is removed. When the incremental cache misses, the job now restores the newest App build state for the same Xcode, keeps only its CompilationCache.noindex, and builds clean against that warm CAS. This uses existing cache entries and needs no more cache space.
  • Caches are restored with actions/cache/restore and saved at the end of the job by a new save-macos-caches action. The large SwiftPM, CLI and App entries are saved only from main. Each pull request run used to save about 1.6 GB, which pushed the repository over its 10 GB quota and evicted the entries of main that all pull requests restore. It also removes about 50 s of cache upload from pull request runs. Mise and GhosttyKit entries are small and slow to rebuild, so pull requests still save them.
  • make lint and make check-localization run in the parallel step beside the build (new checks task with the script tests).
  • make test-app exports each pass's test action log; the CI summary shows when the test runner launch and the first test suite start. (The test-progress heartbeat looked like a 2-minute wait before the first suite; the action log shows it is xcodebuild output buffering.)
  • docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md records the cache findings and measurements, and corrects the CAS note in 016.007.

Measured build job on this pull request (both runs missed the strict App incremental key, because this pull request changes the Makefile and the workflows):

Run Compile "Build app and run tests" step build job
Before (no CAS) about 10 min 782 s 16 min 28 s
After, run 1 (CAS from main, 1.3 GB) about 2 min 20 s 322 s 8 min 46 s
After, run 2 (516 Swift cache hits, 30 misses) 96 s 267 s 7 min 33 s

Verification

  • Unit tests (iOS mirror): new MirrorDocumentTests row test, MirrorReadingAnchorTests (3), MirrorInitialLayoutTests (2); MirrorSessionTests checks that a connection change clears the anchor.
  • iOS 27 iPad: pane switch restores the same marker at 0 pt difference at 4 scroll depths (before: −17 / −31 pt); History → Live Output 0 pt; a temporary 600-line plain-text fixture 0 pt.
  • The six former iPhone UI tests pass on iPad with iOS 26.5 and iOS 27.
  • Full local suite (make test-mirror-ios, iPad, iOS 27): 86 passed, 0 failed (unit and UI). make test-mirror-ios-unit: 67 passed in 28 s.
  • Mac with the CI toolchain (Xcode 26.6 / iOS 26.5): the iPad suite takes 238 s on this branch and 233 s on main, with the same per-test durations.
  • make check, make test-scripts, actionlint on the changed workflows.
  • CI: build, mirror-ios and mirror-android pass.

- History: move "Load Earlier 200 Lines" out of the stack of selectable
  text rows. On iOS 27 a button in that stack gets no taps.
- Live reading: restore a pane's position from a row anchor (row and
  offset in the row), not from an absolute scroll offset. On iOS 27,
  LazyVStack estimates the heights of rows that are not laid out, so the
  same offset showed different text after a pane switch. The document
  view is now one flat LazyVStack of rows, with each chunk of a long
  text block as a row, and it reports the row tops.
- Discard the result of resignFirstResponder() explicitly.
- Make the test UIKey on the main actor and inject it into the test
  UIPress. UIPress is not isolated to the main actor, so its key getter
  cannot make a UIKey.
- Add the test-progress heartbeat to the iOS mirror test targets and keep
  the raw xcodebuild log next to the result bundle. xcsift reports only
  at the end, so a long CI run showed no output for 20 minutes.
- Upload the result bundle and the log also when the job is cancelled,
  for example at its timeout.
- select_ios_simulator.py: evaluate annotations lazily. The Python 3.9
  that macOS includes failed on `X | None`.
@onevcat onevcat changed the title Fix iOS 27 history paging and live reading position in the iOS mirror Fix iOS 27 reader defects and iOS mirror test diagnostics Oct 3, 2026
- Move the iPhone initial split-view layout decision into
  ContentView.initialLayout(for:) and test it with a unit test. The
  UI test for it could run only on iPhone and is removed. The other
  UI tests that ran on iPhone also pass on iPad.
- The fixture Host confirmed a held scroll after 2 seconds, and on a
  slow runner the test checked the loading state after that. Now the
  fixture holds each scroll until the test posts a Darwin notification
  (--mirror-ui-scroll-hold-fixture). MirrorSession takes the scroll
  confirmation timeout as a parameter, and the fixture makes it longer.
- Move the iOS and Android mirror jobs to their own workflows. They run
  for changes to their client (and Mirror/Shared for iOS) and for v*
  release tags. The Mac app workflow does not run for changes to only
  the mirror clients.
- CI runs only the iOS mirror unit tests (make test-mirror-ios-unit).
  The UI suite takes minutes on a hosted runner and runs locally with
  make test-mirror-ios, on one iPad simulator.
- Turn off simulator diagnostics collection for the mirror tests. It
  waited 10 minutes for the simulator after a passing run.
- The compilation cache step cached ~/Library/Developer/Xcode/
  DerivedData/CompilationCache.noindex, but with -derivedDataPath Xcode
  keeps the CAS in build/ci-derived-data. The step never restored or
  saved anything, so each miss of the App incremental cache compiled
  everything (about 10 minutes). Remove it. When the incremental cache
  misses, restore the newest App build state for the same Xcode and
  keep only its CAS, so that the clean build replays compiler outputs.
- Restore caches with actions/cache/restore and save them at the end of
  the job with the new save-macos-caches action. Only main saves the
  large SwiftPM, CLI and App entries: pull request entries filled the
  10 GB quota and evicted the entries of main. Pull requests still save
  the small Mise and GhosttyKit entries.
@onevcat onevcat changed the title Fix iOS 27 reader defects and iOS mirror test diagnostics Fix iOS 27 reader defects and speed up CI Oct 3, 2026
After an incremental build, the first xcodebuild test pass on CI waits
1.5 to 2 minutes before its first test suite runs; later passes start in
seconds. make test-app now exports each pass's test action log, and the
CI summary shows when the test runner launch and the first test suite
start, so that the cause of the wait can be found and watched.
They need no build, so they run in the parallel step with the script
tests (the new checks task) instead of before the build.
Add 016.008: the dead CAS cache path, the CAS fallback, the cache save
scope, the mirror client workflows and the measured job times. Correct
the CAS note in 016.007. The test action log shows that the apparent
wait before the first test suite was output buffering.
Release tags are lightweight. git push --follow-tags does not push them, and
gh release create makes them on GitHub, which sends no tag push event. The
v2026.9.29 release shows only the main push and the release event.
make test runs only the Mac app tests, but AGENTS.md described it as all
tests. test-all runs the script, CLI, Mac app, iOS mirror (unit and UI)
and Android mirror suites one after another, so that make -j cannot start
two xcodebuild runs in the same checkout.
@onevcat
onevcat merged commit ca6efef into main Oct 4, 2026
3 checks passed
@onevcat
onevcat deleted the fix/ios-mirror-ios27-ui branch October 4, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant