Skip to content

Rewrite Cue as a native Expo app over a shared core - #24

Draft
arun279 wants to merge 272 commits into
mainfrom
feat/expo-native
Draft

arun279 wants to merge 272 commits into
mainfrom
feat/expo-native

Conversation

@arun279

@arun279 arun279 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Cue becomes a native app. iOS and Android ship as one React Native app built with Expo,
with platform navigation, gestures, haptics and notifications, over a shared TypeScript core.
The web app is retired with the shells; a PWA, if kept, is Expo's web export of the same screens. When this merges the repository reads as an
Expo project: the Capacitor shells, the web UI and their plumbing are gone, packages/core holds the domain
and the Trakt data layer once, and packages/native is the app.

Status

Every user-visible change lands with its screenshots and recordings attached to its merge
comment below, and each screen ships to TestFlight and Firebase App Distribution from
release/expo as it lands, so the app is tested on a phone, not from clips.

Landed, each part verified before merge (root pnpm check, native jest on both platforms,
Playwright, the fake Trakt lane twice, the native launch flow on a signed simulator build):

  • Workspace split: @cue/core extracted with zero cache busts for shipping users; the core
    cannot import an app, and it typechecks and tests itself without one.
  • Native app scaffold: expo-router with native tabs, a stack per tab, the account modal,
    the platform adapters, a local Swift and Kotlin haptics module, device-code OAuth with
    PKCE, migration of the token and the pending write queue from the previous shell.
  • Sync contract: typed read failures, one retry ladder per failure kind honouring
    Retry-After, an observable rate-limit pause, a retrying state, the mark control's three
    states, one episode mark costing three requests instead of nine on the seeded account.
  • Deterministic checks: Biome warnings as errors, cognitive complexity gated at 15 with a
    downward-only suppression baseline (core at zero), zero type suppressions, zero clones,
    size ceilings that can only be lowered plus a per-PR size delta gate, the Play download
    estimate at Play's reference density, the asset allowlist, CodeQL, and a footprint comment
    recreated on every push with line, bundle, complexity and attribution deltas.
  • Design foundation: tokens gated against the web stylesheet, type roles anchored to Apple's
    and Material's scales, snackbar hosts, the accessibility id vocabulary gated both ways.
  • Maestro launch flow on a signed simulator build in CI, the fake Trakt's seed states and
    fault endpoints, a deterministic native build with its framework linkage asserted.
  • Core cleanup: the browser crypto polyfill gone, the freshness poll mounted on native, one
    view-status shape, one write-outstanding state, the write lock keyed per show.
  • Up Next: swipe to mark with haptics, pull to refresh, the strip in flow, the marquee,
    "On the way", the History footer, the lapsed drawer, every state, light, dark and the
    largest text size; media in its merge comment.
  • The native release lane: TestFlight and Firebase from release/expo, version 2.0.0.
  • Capacitor removal (in progress on its own branch).
  • The core's read layer as query factories and selectors instead of per-screen hooks.
  • The remaining screens: show detail and the episode sheet, Library, Calendar, Search,
    movie detail, History, Profile, Settings, Onboarding.
  • Native conveniences that are not one screen's: the tab shell, predictive back, Dynamic
    Type passes; per-episode local notifications for the shows being watched.
  • The web UI's removal with the PWA served from the Expo web export.

How to read the footprint comment

The merge base has no packages/ tree, so the bundle and complexity base columns read n/a on
this PR. Merge-scoped runs (base pinned to the previous tip) are posted in the comments below
as each part lands.

The workspace split

packages/web is the Vite app. packages/core is @cue/core: the domain, the
Trakt data layer, the durable write queue, the runtime and composition root, the
auth store, the hooks, the stores, the preferences and the URL parsers, plus the
ports each app fills (key-value storage, preference storage, token storage,
haptics, reminders, connectivity, app visibility, the OAuth redirect handoff).
It is TypeScript source with no build step, reached through one wildcard subpath
(@cue/core/domain/up-next), and it contains no .tsx and no .css.

The rules are enforced rather than agreed. dependency-cruiser grew from 8 rules
to 16 over packages, every one of them exercised against a planted violation:
the core cannot import an app, the domain cannot reach the data layer, a port
cannot grow an implementation, the web app owns the DOM and the native app owns
Expo, and neither app may import the other. pnpm check:core-portable asserts
the core's file types out of git rather than out of a config, and biome bans
eight browser and node globals inside it.

Behaviour-preserving for the web app, proven

The persisted query cache is keyed on a buster that used to be a Vite define
hashing three source trees by path, so the workspace move alone would have
dropped every shipping user's cache, and the extraction would have dropped it
again. scripts/write-buster.mjs replaces it with a path-independent shape
witness: it hashes the trees that define every persisted shape, in source order,
with each import statement's specifier collapsed to the digest of the module it
resolves to, so a file that moves or an import that is respelled produces the
same witness, while a field added to a type does not. pnpm buster:check fails
the build while the committed witness and the computed one disagree.

The buster literal is seeded with the value main's own build already produces, so
this branch ships zero cache busts:

Build Shipped PERSIST_BUSTER
main, vite build --mode test d0c3d97c58b8
this branch, vite build --mode test d0c3d97c58b8

Both were read out of the built dist/assets/index-*.js, and main's value also
recomputes from source with the old function's own logic. buster:check at the
tip reads shape b76d12c06a3e, buster d0c3d97c58b8: the two differ, which is
what a mechanism change with no shape change looks like.

packages/native

An Expo app on expo-router, running the shared core through its own composition
root. What is in it:

  • The nine platform adapters that fill the core's ports: an expo-sqlite
    key-value store, expo-secure-store for the token, a synchronous preference
    storage over the same database in its own pref. namespace, expo-network
    for connectivity, AppState for visibility, expo-notifications for the
    reminders planner (inexact triggers, since the exact-alarm permission is
    blocked), and expo-application for the version.
  • A local Expo module, 109 lines of Swift and 127 of Kotlin, implementing the
    seven-verb haptics port with each platform's own system feedback, plus the
    read side of the previous shell's stored preferences.
  • Device-code OAuth with a real S256 PKCE pair, built on expo-crypto rather
    than a hand-rolled encoder, because a phone has no page to redirect.
  • Migration from the previous shell: the token moves to the Keychain and the
    pending write queue is read once and removed, so an upgrade in place lands
    signed in with its undelivered writes intact and can never replay them twice.
  • Four native tabs, a stack per tab, shared detail routes inside every stack, and
    the account area as a full-screen modal.
  • Three config plugins over app.config.ts, which blocks 25 permissions the
    dependency tree would otherwise ask for and adds none of its own; the release
    APK asks for 7.

ios/ and android/ are generated rather than committed, so both projects are
rebuilt from app.config.ts on every build.

What is deliberately not here

The screens. Everything under packages/native/src/screens is a placeholder
that renders real data from the shared hooks with no styling: rows of text, no
artwork, no theme. They land next, on this same branch, as it is worked. The
scaffolding is complete: routing, the composition root, the ports, auth,
migration and the gates are all in place and exercised on a device, so the
screens are the remaining work rather than the risky part.

Also not here, and tracked as the next steps after the screens: the art pipeline
(the shared hook still hands Element to its consumers), the theme port, a
snackbar host on the native side, and the Maestro launch flow.

Verification

  • pnpm check at the root: biome, dprint, cspell, three tsc programs,
    dependency-cruiser over 507 modules and 1,741 dependencies, knip, jscpd,
    buster:check, verify-bundle, and vitest with coverage: 106 files, 875
    tests. Then jest-expo: 8 suites, 70 tests across the ios and android projects.
  • Playwright from packages/web: chromium 239 passed, mobile-chromium 40 passed,
    and the mock-mode equivalence lane 16 passed, which drives 15 scripted flows
    against the local fake Trakt with no interception and fails if any of the six
    write paths stops being sent.
  • Android, both lines: the web shell's assembleDebug plus verify-apk.sh
    (9.9.9 (42), 5 permissions, backup off, every storage domain excluded from both
    channels), and the native app's assembleRelease plus the same check on the
    expo line (7 permissions, same privacy assertions).
  • iOS: the native app built with xcodebuild and run on an iPhone 17 Pro
    simulator against the local fake Trakt. It signed in through the device-code
    grant (the request log carries POST /oauth/device/code with an S256
    challenge, then POST /oauth/device/token with the matching 43-character
    verifier) and painted Up Next from the shared useUpNext hook with the four
    native tabs beneath it.

The numbers

507 files changed, 13,502 insertions, 1,946 deletions. Of those, 338 are renames
(153 byte-identical, 185 carrying an edit), 133 files are new, 23 are modified in
place and 12 are deleted: most of the diff is the move and the import rewrite,
not new code.

The web app's 24,211 product lines become 11,212 in @cue/core and 13,548 in
packages/web, so 45 percent of the product code now runs on both targets, at a
cost of about 550 lines for the ports and seams. packages/native is 2,621
tracked lines: 1,016 of composition root and adapters, 378 of route tree, 323 of
local module (236 of them Swift and Kotlin), 441 of tests and 223 of config
plugins.

@arun279
arun279 force-pushed the feat/expo-native branch 2 times, most recently from b135ec3 to 25e77e7 Compare August 24, 2026 22:42
An Expo SDK 57 app under Continuous Native Generation: `ios/` and `android/` are
prebuild output and are not committed, so every native fact lives in
`app.config.ts` or in one of the three config plugins. `expo prebuild` in SDK 57
clears and regenerates both directories by default, which makes a committed
native tree a second copy that can silently disagree with the plugin that is
supposed to own it.

Four facts in the app config are release blockers, and each is pinned by a test
because none of them is visible until a build is in somebody's hands: the bundle
id is `app.cuetracker` on both platforms, because on Play a different
`applicationId` is a different app rather than an upgrade; the orientation set
and tablet support are the shipping app's; and the colour scheme follows the
system, because the app has three themes. `BUILD_NUMBER` and `APP_VERSION` come
from the environment, which collapses the two mechanisms the Capacitor line uses
into the one prebuild already applies to both projects.

`with-ios-scene-lifecycle` adopts the UIKit scene life cycle, without which an
app linked against the iOS 27 SDK installs and then refuses to launch. It is the
spike's plugin with the two corrections its review named: the scene cold launch
reads `connectionOptions.urlContexts` and `.userActivities`, and it reconstructs
real launch options for `startReactNative`, without which `Linking.getInitialURL()`
answers null and every cold-start deep link is discarded.

`with-android-privacy` re-establishes `allowBackup="false"` and the app's own
data-extraction rules. Android's default is `true`, so a prebuilt app silently
opts back in and turns a claim PRIVACY.md, docs/index.html and README.md all make
into a false one. `verify-apk.sh` now reads the result out of the built APK
rather than trusting the plugin: backup off, the rules resolved through the
resource table and their contents checked, no `<include>`, and the merged
permission set pinned exactly per line. The Expo line's set is measured off a
release prebuild and differs from the Capacitor line's, which is why the script
takes the line as an argument instead of pretending one list covers both.

`with-android-build-memory` raises the Gradle JVM heap from the template's
`-Xmx2048m` to `-Xmx4g`. D8 runs out of heap merging this app's dex archives at
the template's size, and the failure is an `OutOfMemoryError` inside
`:app:mergeDexRelease` rather than anything the app's own code answers for. It
is a plugin rather than a checked-in `gradle.properties` for the same reason as
the other two: `android/` is prebuild output, so a hand-written value does not
survive the next `expo prebuild --clean`. The release build is the assertion:
without it, `assembleRelease` fails.

Twenty-five permissions the dependency tree adds are blocked in the app config
and each group is explained there: Expo's four optional template permissions,
expo-secure-store's biometric pair, and expo-notifications' push receive, Play
install-referrer binding and per-OEM launcher badge set. A TV tracker asking
eight launcher vendors for shortcut access is exactly what the permission gate
exists to stop.

Three pnpm overrides come with the package. `expo` is a runtime dependency, so
`pnpm audit --prod` walks its build tooling too, and `@expo/cli` and
`@expo/metro-config` pull transitive `brace-expansion`, `nanoid` and `postcss`
versions with high-severity advisories against them. Each override is written as
a version range rather than a bare name, so it rewrites only the versions the
advisory names and leaves every other copy in the tree alone. `pnpm audit --prod
--audit-level=high` is clean with them and reports five highs without them.

The package joins every gate in this commit rather than later: biome, dprint,
cspell, `pnpm -r typecheck`, knip, jscpd, the sixteen dependency-cruiser rules,
and jest-expo under both the ios and the android preset. `pnpm check` runs the
native suite, and ci.yml gains a job per platform that generates the projects and
compiles them, so a config plugin that no longer matches the Expo template fails
in CI rather than on somebody's machine. The release gate's required list and its
deadline move with them.

One gate found its own bug immediately: `react` was declared at two versions
across the workspace, pnpm hoisted one and nested the other, and a Radix
package's peer bound the web app's component library to a different React
instance than its renderer. `workspace-versions.test.ts` pins one range per
package across every manifest.
Every member of `CueRuntime` on this target, and almost no new logic: the write
queue, the op-log restore, the startup reconcile, the activities freshness gate,
the persisted query cache, the sign-out teardown and the dead-token exit all
arrive with `@cue/core` and are wired here rather than rewritten. The spike's
parallel context, six members of the port under a different name, is not carried
forward in any form.

The module carries two native classes because both platforms need both, and an
Expo local module is one native compilation unit. `CueHaptics` is the seven-verb
vocabulary the Capacitor shells already ship, ported to Swift and Kotlin:
generators built once and kept warm on iOS, action-oriented
`HapticFeedbackConstants` with their per-API-level fallbacks on Android.
`expo-haptics` cannot be the answer here: it exposes no `prepare()`, so a
threshold tick arrives late enough to read as belonging to some other gesture,
and its Android side funnels everything into a raw `Vibrator` waveform, which is
what Android's own haptics guidance tells you not to use for touch feedback.
`CueLegacyPreferences` reads Capacitor Preferences through each platform's own
key shape, which are not the same shape: iOS prefixes the storage group onto the
key inside `UserDefaults`, Android makes the group the shared-preferences file
name and leaves the key alone, and a migration that assumes one reads nothing on
the other platform.

Two key-value stores behind one interface. The Keychain holds the token and
nothing else, at `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, which is what finally closes
the iOS half of the storage caveat: the token no longer rides in a store every
device backup includes. `expo-sqlite/kv-store` holds everything durable, and
because it and the preferences now share one database, preferences are written
under `pref.` rather than `cue.`: a sign-out that cleared `cue.` would take the
durable write queue, the freshness baseline and the install marker with it, and
losing the install marker makes the next launch look like a fresh install to the
purge, which then clears the token.

Two things run before the runtime is built, in that order, because both change
what the token store holds. The reinstall purge clears the Keychain when the bulk
store carries no install marker: Expo documents that SecureStore items survive an
uninstall on iOS, so without this a user who deletes Cue and reinstalls it comes
back apparently signed in with a stale token and none of their local state. Then
the Capacitor migration, which is pure over a port and therefore a unit test
rather than a device session: it adopts the legacy token on every boot and leaves
it in place while a Capacitor build is still a rollback target, takes the legacy
op log away on read because a replayed op is a duplicate play, parses that op log
through a schema because it was written by another build at an unknown version,
and seeds the first-mark caption as seen.

The Web Crypto surface the shared OAuth code is written against is installed from
a plain function rather than from an import side effect, so organize-imports
cannot move it, and rather than from a React component, so it is not a global
write at a moment nothing controls. `TextEncoder` is deliberately not shimmed: a
hand-written UTF-8 encoder is right until it meets a non-latin1 input, and a
crash at first sign-in is better than a challenge that hashes the wrong bytes.

The `KeyValueStore` contract suite is `@cue/core`'s, run over both native
backends rather than transcribed, which is what the `vitest` shim under
`__tests__/support` is for. The composition root is rendered under RNTL for the
one property nothing else can check: which of the two branches the gate takes
after the purge and the migration have had their say.

`neverRejects` moves the "none of these ever rejects" half of the reminders port
into the port itself, so the two adapters state the policy once between them
instead of once each.
…modal

Four tabs, fixed at four for every user, on four distinct paths. Fixed, because
media visibility is a reversible preference two screens away in Settings and a
navigation bar that changes item count when a switch flips is the least
predictable thing a shell can do: Apple's tab-bar guidance names the case
outright and Material's navigation bar says destinations do not change, so a
movies-only user keeps four tabs and the two with nothing in them say why.
Distinct paths, because the native-tabs navigator does not forward
`initialRouteName`, so four groups all serving `/` land on whichever is
alphabetically first; a path per tab works around that and makes every tab
deep-linkable besides.

Show detail, movie detail and the episode sheet are written once, in an
array-group directory that expo-router duplicates into each of the four groups.
The tab bar stays visible on push and each tab keeps its own back stack, which is
what a `UINavigationController` inside a tab does natively and what the per-tab
duplication would otherwise cost.

Two facts about that shape were found by running it rather than by reading about
it, and both are recorded where they bite. `unstable_settings`' per-group keys
carry no parentheses, because `matchLastGroupName` strips them before the
lookup, and with a parenthesised key every group silently falls back to the first
route in the tree: four tab stacks opening on `show/[showId]` with no id, four
GETs for `/shows/NaN` on every launch, and nothing on screen to show for it. And
even with the key right, `unstable_settings` had no effect on a tab press, so the
layout that ships declares each tab's own root first and relies on declaration
order, which is what the navigator actually falls back to.

The account area is one full-screen modal stack over the tabs rather than a route
inside whichever tab happened to be selected: a modal always has a real parent
and always dismisses back to where the user was, and full-screen rather than the
default because `"modal"` resolves to a page sheet on iOS and this is a task area
with three screens and its own back stack.

The episode route stays a child of the show route and presents as a `formSheet`
at the web app's own two detents, so a cold deep link paints the show underneath
and dismissing is one pop, with UIKit owning the physics.

The URL state the web app validates is validated here by the same two parsers,
because a route parameter is untrusted text on both targets. Path ids get the
same treatment in `route-params.ts`: `Number("")` is `NaN` rather than an error,
so a link that goes nowhere reaches the not-found screen instead of becoming a
request for `/shows/NaN`.

The screens are placeholders that render real data through the shared hooks, and
carry no styling: what they have to prove first is that the read path, the
persisted cache and the write queue already work here.
Run repository lint and the aggregate check with error-on-warnings so stale suppression diagnostics block the gate.
Enable Biome cognitive complexity errors at the default threshold and document each existing exception at the function that owns its inherent branching.
Measure the initial load and all JavaScript and CSS with the size-limit file plugin after a clean web build. Keep both budgets at the measured baseline plus twenty-five percent.
Persist the adopted token digest in bulk storage so each legacy token is migrated once. Cover relaunch after sign-out, changed legacy credentials, and rollback key preservation.
Render a Done action in the profile header and dismiss the full-screen account stack through Expo Router. Cover the initial route and dismissal behavior on iOS and Android.
Export both native platforms and measure their raw Hermes bytecode against scoped size-limit budgets in the native Android job.
Run Biome at threshold one over an unsuppressed product-source shadow and emit deterministic aggregate metrics. Fail if Biome output no longer matches the parser contract.
Classify tracked product TypeScript lines as code, comments, or blank and report Sonar-style density per package and in total without enforcing a threshold.
Build web and Expo outputs in a selected tree, apply the head size definition, and emit size-limit JSON without allowing a missing artifact to become a successful zero.
Append optional bundle size, cognitive complexity, and comment density tables to the footprint report while preserving its existing output when metrics are absent.
Install the pinned toolchain, build both worktrees in one run, collect size, complexity, and comment metrics, and pass the combined results to the existing single-comment report.
Assert the release APK byte size immediately after assembly and before manifest verification, using Google Play's 200 MB compressed download ceiling.
Parse every variant in the Xcode thinning report and reject the signed build before upload when its largest compressed app size exceeds 200 MB.
Add the npm ecosystem at the workspace root so Dependabot proposes lockfile updates for every pnpm package.
Analyze JavaScript and TypeScript source plus GitHub Actions on pull requests, main pushes, and a weekly schedule with pinned CodeQL actions.
Add the CodeQL context to the shipped-commit gate and read that context from its separate workflow check suite while keeping CI job matching suite-scoped.
Keep release path filters, architecture rules, spelling, required-check tests, and the persisted source witness aligned with the new non-shipping gates and reports.
Give each language matrix leg a distinct required context and run both contexts on release branches so every mobile release can satisfy the shipped-commit gate.
Every failure the app has to survive is a property of the server, so the mock
now models them rather than leaving each test to intercept requests: a rate
limit that closes the window with Retry-After, a 5xx, a slow answer, a
connection that hangs, a connection that dies, and a write Trakt accepts but
never applies. Armed by MOCK_TRAKT_FAULTS at boot or POST /__fault at runtime.

Also expose the response headers the app reads. Retry-After and X-Pagination-*
are not CORS-safelisted, so cross-origin the browser was handing the app a
response with them stripped, and the mock silently stopped modelling pagination
and backoff at all.
Every read turned its failure into a bare Error, so a screen could tell that
something went wrong and nothing else. A rate limit, an outage and a 5xx take
different copy and different retry policy, and the app was calling all of them
the same thing. unwrapRead is now the one place a read becomes a throw, and it
throws the failure.

The shared rate-limit pause becomes observable for the same reason: it is the
app's answer to what is happening and when it retries, so the UI can read it
instead of inferring an outage from a query that happens to be failing. It is
also raised for the 429 that spends a read's retry budget, since the window is
still closed whether or not that particular read had attempts left.
Two things the owner sees are the app describing its own state wrongly. A rate
limit reads as "Trakt unreachable" over data that is on the screen and fine,
because every read failure collapsed into one boolean and a query never retried
itself. And a marked row stays green for as long as its write stays undelivered,
because the check waited for a progress re-read that a deferred write never
triggers, so several rows sit green at once and the app looks stuck.

The rules now live in one pure module both apps read from:

  - A rate limit is not an outage. It says so, says when reads resume, and
    offers no Retry, because the retry is automatic. Once the window reopens
    and the read has given up, it says that instead, with a Retry.
  - A failed refresh over cached content is a note, never the screen's error.
    With nothing cached the strip stays silent and the screen carries it, so
    "showing your cached data" is never printed over an empty screen.
  - Reads retry themselves, waiting exactly as long as Trakt asked, on the same
    backoff ladder the write queue already uses.
  - A mark advances its row on the clock. The durable queue guarantees delivery,
    so nothing about the row waits for the round trip. Green is the undo window
    and nothing longer; past it the row reads as advanced, and a mark still on
    its way is a quiet indicator rather than a green check.

The three view hooks that hand-copied the query status fields now extend the
shared shape, which is what queryStatus was for.
The strip takes its screen's read status and renders whatever the contract
returns, deciding nothing. The queue rows and the marquee take their grammar
from the shared hook, so the check has a third state: advancing, the row that
has moved on while Trakt names its next episode. It is dimmed rather than green,
because green means take-back-able and this no longer is, and it carries a quiet
dot once the mark is past its undo window and still on its way.

A screen with nothing cached now says what actually failed under its title,
instead of blaming the connection for a rate limit.
The one native screen that marks anything takes its check grammar and its
ambient line from the same module the web screens do, so the contract is proved
consumable on this target before the visual layer exists.
The mock lane gains the two the owner reported: a 429 burst that must not read
as an outage and must clear itself, and a mark that must advance its row in the
frame of the tap and clear its pending note when the write lands. Both are
driven by arming the fake Trakt rather than by intercepting requests, because
both are properties of Trakt's answers and the native app has to survive the
same ones.

The hermetic specs move with the behaviour. Two of them failed a single read and
expected an error state; a single failed read is now absorbed by the retry, so
one becomes the case that proves the blip never surfaces and the others fail for
as long as it takes the budget to run out. Retry appears only once the app has
stopped trying on its own.
Fastlane runs shell steps from the fastlane directory, so the relative
IPA path never resolved and the version check failed after a successful
archive. The lane now checks the absolute path gym returns.
@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Capacitor shells removed, 4,300 lines deleted and 147 added (net -4,153). Bundles: web initial load -3.3 kB (135.8 kB), web all js and css -5.2 kB (225.6 kB), Expo iOS bundle -43 B (5.03 MB), Expo Android bundle -23 B (5.06 MB), Play download -87 B (16.85 MB). Functions over cognitive complexity 15 unchanged at 11, worst 41, all in the web UI.

The Capacitor iOS and Android shells, their five packages, the platform seams, the ios and android CI jobs and the Capacitor release lanes are gone; the Expo app is the only native codebase. The reader that migrates the old app's data on first launch stays until its sunset.

Expo substitutes public environment values when Metro builds the JavaScript bundle. Supplying the client id only during prebuild left the shipped bundle empty and made startup throw.
A single Expo interface keeps product behavior and release gates focused on the UI that ships.
Headless checks against the fake Trakt preserve transport guarantees without coupling them to a rendered interface.
Removing dependencies and exports without a remaining consumer keeps the native workspace and its quality budgets honest.
@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Android launch crash fixed, 118 lines added and 3 deleted. Bundles: web unchanged, Expo iOS bundle -99 B, Expo Android bundle +8 B, Play download -11 B.

Build 1201 died at boot with "EXPO_PUBLIC_TRAKT_CLIENT_ID is not set". The release workflow set the variable on the prebuild step only, and Metro inlines it while bundling inside the Gradle and Xcode builds, which ran without it; both platforms shipped an empty client id. The Fastlane steps and the CI Android bundle now receive it, a workflow test requires it on every bundle-producing step, and a new android-e2e job installs the signed CI build on an API 35 emulator and fails on any fatal exception at launch.

…ve-web-ui

# Conflicts:
#	.dependency-cruiser.cjs
#	.github/workflows/ci.yml
The merge brought two release steps that read the old repository variable
name beside two that read the new one. The web-only tsconfig the
dependency-cruiser options pointed at left with the web app, so the
option and its import go too.
@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the web app removed, 30,797 lines deleted and 891 added (net -29,906). Bundles: Expo iOS bundle -1.8 kB (5.03 MB), Expo Android bundle -1.9 kB (5.06 MB), Play download -100 B (16.85 MB); the web size ceilings left with the web build. Functions over cognitive complexity 15: 0 (was 11, all in the web UI); worst function now 15.

The DOM application, its unit tests, its Playwright suite and its Vite tooling are gone; the Expo app is the only UI over the shared core. Twelve network-behaviour checks that only the browser suite exercised (per-flow request budgets from the fake Trakt's journal, the fault modes, token refresh, the rate-limit pause) now run headless in the core's harness against the same fake Trakt, each proven by a mutation. The repository-level CI tests moved to a root test project. Coverage percentages rose in every category.

Reads need reusable options for screens, prefetches, cache writes, and tests without React subscriptions. Move pure values and module state to their owning layers, and gate the boundary so new wrappers cannot rebuild the old shape.
The home screen should compose its own query data while behavior remains in focused hooks. Scoped mark selectors keep unrelated rows out of record updates, and the performance baseline now exercises the real subscription path.
Native owns the show and episode compositions, while core keeps only reusable query factories. Mark feedback now presents from the write controller that owns each outcome.
Library grouping is pure reusable logic, while the native route owns its query subscriptions and screen-specific composition.
Screens and the reminder driver now subscribe through shared calendar query factories, while calendar slicing and visibility remain pure domain functions.
Search keeps only its interaction state as a core hook. Native screens now subscribe through the search and movie query factories they render.
The diary owns its infinite query and grouping, while exact-play removal remains a tested core write controller. Profile reads now use their query factories directly.
Every included core source directory now has a deterministic coverage floor. The final thin library-entry wrapper is replaced by a direct selection from the shared snapshot.
@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the core's read layer reshaped, 1,662 lines deleted and 1,403 added (net -259; core source -787, native screens and tests +528). Bundles: Expo iOS bundle -10.1 kB, Expo Android bundle -10.0 kB, Play download -3.0 kB.

Reads are query option factories under the core's queries/ (14, each taking the runtime first), module state is read through selectors under stores/, and the pure derivations the page-shaped aggregates were hiding live under domain/; the hooks directory went from 3,932 lines to 2,591 and keeps only behaviour hooks. Screens compose their own reads. Two new gates: every use-named file exports exactly one hook and nothing else, and queries/ cannot import React; the scoped-revalidation rule now also covers queries, stores and native routes. Four mutations bit. Coverage thresholds rose (overall lines 79.8 to 86.9 percent) and the render-count gate now measures real subscriptions through a seeded query client instead of mocked hooks.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the startup-time gate, 348 lines added and 12 deleted. Bundles: Expo iOS bundle +478 B, Expo Android bundle +567 B, Play download +155 B.

The simulator lane now measures a returning user's launch: the app publishes the time from process start to its idle marker (React Native's own startup timing) in the accessibility tree, and a second-launch Maestro flow reads it and asserts it against a ceiling kept in a ratchet file that carries its measurement, run and date and can only be edited downward. The ceiling is 600 ms from a measured 466.5 ms plus the observed run-to-run band; a planted three-second block at boot failed the flow on a throwaway PR, and raising the ceiling fails the ratchet test. The runner-timed launch-to-idle duration is reported in the job summary.

@github-actions

Copy link
Copy Markdown

Diff footprint

area added removed net
product (packages/*/src/) 14116 0 +14116
tests (packages/*/test/) 14352 0 +14352
e2e (packages/*/e2e/) 0 0 +0
other 12040 51041 -39001
total 40508 51041 -10533

Product lines: code +9997 / -0 (net +9997), comments +2997 / -0, blank +1122 / -0
Line types are split by line prefix after leading whitespace.

Bundle size

bundle base head delta limit
expo ios bundle (raw) n/a 5.02 MB n/a 5250 kB
expo android bundle (raw) n/a 5.05 MB n/a 5400 kB
play download (xxxhdpi arm64) n/a 16.85 MB n/a 20000 kB

Complexity and comments

metric base head delta
functions over cognitive complexity 15 n/a 0 n/a
worst cognitive complexity n/a 15 n/a
mean cognitive complexity (functions scoring 2 or more) n/a 4.29 n/a
product comment density n/a 22.43 percent n/a
core comment density n/a 23.93 percent n/a
native comment density n/a 19.12 percent n/a

The merge base does not contain the measured packages, so its columns read n/a.

Expo Atlas top contributors

platform contributor transformed bytes
iOS react-native-reanimated 1548313
iOS react-native 1485475
iOS expo-router 1237023
iOS zod 662854
iOS Cue app 548458
iOS react-native-gesture-handler 335466
iOS react-native-svg 294809
iOS react-native-worklets 208108
iOS expo 198113
iOS react-native-screens 174484
Android react-native-reanimated 1548313
Android react-native 1497122
Android expo-router 1215699
Android zod 662854
Android Cue app 548216
Android react-native-gesture-handler 339545
Android react-native-svg 294824
Android react-native-worklets 208108
Android @expo/ui 204031
Android expo 198117

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.

2 participants