Skip to content

Speed up mobile cold-start to first content#14259

Merged
raymondjacobson merged 1 commit intomainfrom
mobile-api-perf
May 7, 2026
Merged

Speed up mobile cold-start to first content#14259
raymondjacobson merged 1 commit intomainfrom
mobile-api-perf

Conversation

@raymondjacobson
Copy link
Copy Markdown
Member

Summary

Cold-start to first Trending content on mobile was ~7.6s; this PR drops it by removing two redundant gates that blocked the navigator on a network re-confirmation of an already-cached account, plus a few skeleton/flash fixes that surfaced once content rendered earlier.

Headline numbers (release build, iOS sim, signed-in cold start):

  • nav_container.ready: 5,190ms → ~3,400ms (-1.7s)
  • trending.fetch.start: 5,557ms → ~4,800ms (-700ms)
  • trending.fetch.end: 7,604ms → ~6,700ms (-900ms)

The PR is paired with the prior `Add perf marks` commit which adds the instrumentation used to measure these segments.

Changes

Remove redundant account-status gates

  • NavigationContainer no longer returns null while useAccountStatus is IDLE/LOADING. The deep-link handler reads hasAccount/accountHandle from useCurrentAccount's sync placeholder, which is populated from local storage — no need to wait on the network.
  • RootScreen.isLoaded removed. Stack.Navigator renders immediately. showHomeStack is derived from cached values that are present on the first render. The native splash dismisses on first commit. Player reset moved to useEffectOnce. Chat connect still waits on accountStatus === SUCCESS.

Make sync local-storage actually sync on RN

  • LocalStorage.getJSONValueSync was silently returning null on mobile because AsyncStorage.getItem returns a Promise — the catch-and-return-null path swallowed it. Added an in-memory sync cache (preloadSyncKeys / preloadAccountSyncCache) and a tiny gate inside <App> that waits for the ~30–50ms preload before mounting. Without this, removing the gates would flash the sign-on screen on every cold start.

Skeletons + flash fixes

  • TrendingScreen now passes a TrendingLineupSkeletons (TrackLineup with empty data + isPending) to <ScreenSecondaryContent> so the deferred-render window shows real-looking skeletons instead of an empty page.
  • ScreenSecondaryContent skips the iOS FadeIn when a skeleton is provided — the skeleton is already the visual placeholder, fading the swap-in caused a brief opacity-0 flash.
  • TrendingScreen memoizes its header function. Without it, every parent re-render produced a new function reference, Screen.setOptions({ header }) re-ran, and React Navigation rebuilt the header — remounting AccountPictureHeader and re-firing the profile-picture image fetch (visible flashing on cold start).

Test plan

  • iOS release build, signed-in cold start: no sign-on flash, skeletons appear immediately on Trending, real tiles replace them without a fade flash, profile picture in the top-left header doesn't flash
  • iOS release build, signed-out cold start (or fresh install): lands on sign-on directly, no flicker
  • Android release build, signed-in cold start
  • Deep link cold start (audius://trending, audius://feed, profile URLs) routes correctly
  • Navigate between Feed / Trending / Explore tabs: no regressions to header, skeletons look right
  • Sign out → sign in flow still routes to HomeStack
  • Track playback works (player reset effect still fires)

Follow-ups

  • Same skeleton + memoized-header treatment for Feed / Explore / Library / Profile
  • Persist QueryClient cache to MMKV (separate PR coming on top of this one) — eliminates the 2.4s network round trip on warm starts
  • Switch redux-persist storage to MMKV for the remaining ~800ms PersistGate cost
  • Audit other consumers of useAccountStatus to confirm nothing relies on the now-removed gating semantics

🤖 Generated with Claude Code

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 7, 2026

⚠️ No Changeset found

Latest commit: ecc26cb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

The navigator + RootScreen previously waited on a network re-confirmation of
the locally-cached account before rendering, gating the entire UI on a
~2.7s API call. Both gates now release as soon as we know the answer from
local storage; the server check continues in the background.

- Remove the accountStatus return-null gate in NavigationContainer.
  hasAccount/accountHandle come from useCurrentAccount's synchronous
  placeholderData (sourced from local storage), so getStateFromPath has
  the values it needs from the first render.
- Drop the RootScreen isLoaded gate; render Stack.Navigator immediately,
  derive showHomeStack from the cached user, dismiss the native splash on
  first commit. Move the player-reset side effect to useEffectOnce. Chat
  connect still waits on accountStatus === SUCCESS.
- Add an in-memory sync cache to LocalStorage (preloadAccountSyncCache)
  because the existing getJSONValueSync silently returned null on RN —
  AsyncStorage.getItem returns a Promise that the catch-and-return-null
  path swallowed. Mobile preloads the account/user keys at module load
  and App gates render on that ~30-50ms read so
  useCurrentAccount.placeholderData has real values for the first render
  (no sign-on flash on a logged-in cold start).
- Pass a TrendingLineupSkeletons (TrackLineup with empty data + isPending)
  to ScreenSecondaryContent so the deferred-render window shows real
  skeletons instead of a blank page. Skip the iOS FadeIn entrance when a
  skeleton is provided to avoid the opacity-0 flash on swap-in.
- Memoize TrendingScreen's header function so Screen.setOptions doesn't
  re-set the header on every parent re-render, which was remounting
  AccountPictureHeader and re-firing the profile-picture image fetch.
- Pass TrendingHeader (the Tracks/Underground/Winners pills) as both the
  skeleton and children of ScreenPrimaryContent. Same JSX in same position
  reconciles to the same instance across the isScreenReady flip — the
  pills appear immediately and don't pop in / shift content.
- Stabilize Artwork's imageSource by memoizing on URI string instead of
  recomputing { uri } each render. Without this, AnimatedImage saw a new
  source object every parent re-render and reloaded the image (visible as
  a flash on cold start, especially the top-left profile picture).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@raymondjacobson raymondjacobson merged commit e67c7a2 into main May 7, 2026
14 of 15 checks passed
@raymondjacobson raymondjacobson deleted the mobile-api-perf branch May 7, 2026 17:27
dylanjeffers added a commit that referenced this pull request May 7, 2026
Main added syncCache (private), preloadSyncKeys, and preloadAccountSyncCache
to the LocalStorage class in #14259, which broke the web typecheck on the
PR merge ref. Add the public methods to the mock and cast the return so the
private member is satisfied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request May 7, 2026
The mobile cold-start PR (#14259) added preloadSyncKeys/preloadAccountSyncCache
plus a private syncCache to LocalStorage but didn't update the web test mocks,
breaking @audius/web typecheck on main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request May 7, 2026
Main added syncCache (private), preloadSyncKeys, and preloadAccountSyncCache
to the LocalStorage class in #14259, which broke the web typecheck on the
PR merge ref. Add the public methods to the mock and cast the return so the
private member is satisfied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant