Nativestarter
Showcases

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.

Published

Not affiliated with, endorsed by, or officially connected to Bluesky Social PBC. Bluesky is a trademark of Bluesky Social PBC.

Licence
MITCopyright 2023–2026 Bluesky Social PBC
Pinned commit
2bfb94d2026-09-14
Backend
atproto dev-env with fake accounts and data

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. 1

    Assess the app

    Done

    Static read of the repository: native surface, custom native code, patches, OTA and the hard parts.

    assess-rn-app

  2. 2

    Plan the checkpoints

    Done

    127 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. 3

    Capture baselines

    Done

    Run the React Native app on the iOS simulator and Android emulator against fake data, and capture each screen with Maestro.

    spec-screen

  4. 4

    Port each screen

    Done

    All 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.
Read the Bluesky sample report

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.