Migrating Bluesky to SwiftUI and Compose
The whole app, rebuilt in SwiftUI and Jetpack Compose: all 78 screens on both platforms. Three screens are measured in full against the React Native app, and the same checks are now running across the rest.
Not affiliated with, endorsed by, or officially connected to Bluesky Social PBC. Bluesky is a trademark of Bluesky Social PBC.
- Upstream
- bluesky-social/social-app
- Licence
- MITCopyright 2023–2026 Bluesky Social PBC
- Pinned commit
- 2bfb94d2026-09-14
- Backend
- atproto dev-env with fake accounts and data
- Native port
- iOS (SwiftUI)Android (Compose)
The whole app
Every screen of Bluesky now exists in SwiftUI and Jetpack Compose. Built is not the same as verified: the checks that measured three screens in full are running across the rest.
- Screens built on both platforms
- 78 of 78
- Checkpoints
- 127
- iOS tests
- 1,487
- Android tests
- 1,315
As of 25 October 2026. The checkpoint plan was accepted on 22 October 2026. The public repositories hold the three measured screens as of 22 October 2026.
Ported beyond the measured screens
- Sign-in and accountsSign in with 2FA and password reset, sign-up, onboarding, the account switcher and every account dialog
- ComposerPosts, replies, quotes, media, link cards, GIFs, threads, drafts, languages and who can reply
- ChatConversations, group settings, join requests, export, appeals and reports
- ModerationLive label moderation, the report dialog, label appeals, labeller profiles and muted words
- DiscoverySearch and Explore, Home pinned feeds, trending, bookmarks and every profile tab
- Profile and shellEdit profile, the side drawer, suggested follows, share and the image lightbox
- MediaVideo autoplay on scroll and external players with consent
Measured in full
Three screens taken through every gate on both platforms. The results below are for these three.
Home feed
A long, virtualised list of posts with images, embeds and pull to refresh.
- src/view/screens/Home.tsx
- src/view/com/posts/PostFeedItem.tsx
Post thread
Nested replies, a highlighted post and actions on each item.
- src/screens/PostThread/index.tsx
Profile
Header, counts, tabs and the profile's own feed. Video is out of scope.
- src/view/screens/Profile.tsx
How the Pack runs it
- 1
Assess the app
DoneStatic read of the repository: native surface, custom native code, patches, OTA and the hard parts.
assess-rn-app
- 2
Plan the checkpoints
Done127 checkpoints, accepted on 22 October 2026
Order every screen and the modules they depend on, then accept the plan against its hash.
plan-checkpoints
- 3
Capture baselines
DoneRun the React Native app on the iOS simulator and Android emulator against fake data, and capture each screen with Maestro.
spec-screen
- 4
Port each screen
DoneAll 78 screens on iOS and Android, as of 25 October 2026
One checkpoint per screen, keeping Bluesky's existing design on both platforms.
port-screen-swiftui, port-screen-compose
Results for the measured screens
- Screenshots compared
- 35
- Three screens, photographed in every state they reach — loaded, empty, mid-error — on both platforms. One screen in three states is three screenshots, which is why 35 is larger than three. Twenty on iOS, fifteen on Android, every pair captured in the same run against the same seeded server.
- Screenshots over the limit
- 0 of 35
- A screenshot is rejected if more than two pixels in a hundred differ from the React Native original. None were. The worst was 1.94 in a hundred. Getting there closed two real defects and one error in the measurement itself, all four listed further down.
- Close, but not identical
- 26 of 35
- Between one and two pixels in a hundred differ. That is not a pass. Each one needs someone who did not write the port to accept it in writing, and the owner did, on 22 October 2026, by setting the limit at two in a hundred rather than by reviewing all 26 images.
- Measurement error
- 0.00%
- Photograph the same app twice and the two images are identical, pixel for pixel. That is what makes the other numbers mean anything: a difference we report is the port, not the camera. It held across a full re-run on a different day.
- Missing or extra elements
- 0
- Before comparing pixels, the two screenshots are split into bands of content and matched up. Every element on every screen has a counterpart in the other app: the same things, in the same order. Nothing was dropped and nothing was invented.
- Analytics events that match
- 6 of 6 flows
- Both apps were driven through the same six journeys while every analytics event was logged, then the two logs were compared event by event and in order. All six match. Android only: the iOS logs are not captured yet.
- Defects still open
- 1
- Swiping back does not close a thread on iOS, where React Native does. A person found it by running a check the port's own report had listed as outstanding; the automated checks do not test navigation and did not catch it.
What the checks caught
A migration that reports only passes cannot be told apart from one that was curated. Every defect the gates found is here with its cause and what happened next, including the one still open and the one that turned out to be an error in the measurement rather than in the port.
- ComposeClosed4.87% to 1.56%
The port drew blank space where React Native ended the feed
- Found by
- The pixel diff on the home feed, after a reseed changed how much content the feed had.
- Cause
- React Native draws an end-of-feed block when it runs out of posts. The port drew nothing, so every pixel of that block counted as a difference.
- Outcome
- The port now draws the same block. The screen went from a fail to drift inside the gate.
- SwiftUIClosed2.81% to 1.60%
A feed slice two pixels short shifted every row below it
- Found by
- The pixel diff on a feed slice carrying two stacked rows.
- Cause
- The slice was two pixels shorter than React Native's. Two pixels at the top pushed everything under it out of alignment, so one small error read as a large one across most of the screen.
- Outcome
- The slice height now matches. The screen went from a fail to drift inside the gate.
- ComposeClosed
Four screens were failing on a scrollbar that was still fading
- Found by
- Four screens each losing 0.40% to 0.44%, consistently, with no visible difference in the diff image worth that much.
- Cause
- Not a defect in the port at all, but an error in the measurement. Android's own scroll indicator was caught mid-fade on the first screenshot of a flow and was absent from every later one, so the same screen measured differently depending on when it was captured.
- Outcome
- The flows now wait for the settled screen both apps reach. Separately, the port now draws a scroll indicator of its own, built from the platform's geometry and timing and matched to React Native within four pixels of position. That change moves no number, which is the point: it was the right thing to build, not a way to pass.
- SwiftUIOpen
Swipe back does not pop the thread
- Found by
- A human running a check the port's own report had already written down as outstanding. The parity pipeline does not test navigation and did not find this.
- Cause
- Not identified. Both pop gestures are installed, enabled and two deep in the stack, but the recognizer is never asked whether it may begin, which rules out the delegate, the enablement and the API.
- Outcome
- None yet. It is published as open rather than left out, and it is the reason navigation is listed as not checked on the parity page.
Run the same Pack on your app.
The skills used on Bluesky are the ones you install.