Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion packages/common/src/services/local-storage/LocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,46 @@ type LocalStorageConfig = {

export class LocalStorage {
localStorage: LocalStorageType
// In-memory mirror of select keys so getItemSync returns real values on
// platforms where the underlying storage is async (AsyncStorage on RN).
// Web's localStorage is sync, so the cache layer is a no-op there.
private syncCache: Map<string, string | null> = new Map()

constructor(config: LocalStorageConfig) {
this.localStorage = config.localStorage
}

/**
* Pre-populate the sync cache for keys we need to read synchronously
* during first render (e.g. account/user used by `useCurrentAccount`'s
* placeholderData). Call once at app boot before mounting React.
*/
preloadSyncKeys = async (keys: string[]) => {
const values = await Promise.all(keys.map((k) => this.getItem(k)))
keys.forEach((k, i) => this.syncCache.set(k, values[i] ?? null))
}

/**
* Convenience: preload the keys read by `useCurrentAccount.placeholderData`
* so the placeholder returns real data on first render. Required on RN
* where the underlying AsyncStorage is async.
*/
preloadAccountSyncCache = () =>
this.preloadSyncKeys([AUDIUS_ACCOUNT_KEY, AUDIUS_ACCOUNT_USER_KEY])

getItem = async (key: string) => {
return await this.localStorage.getItem(key)
}

getItemSync = (key: string) => {
return this.localStorage.getItem(key)
if (this.syncCache.has(key)) {
return this.syncCache.get(key) ?? null
}
// Web's localStorage.getItem is sync; AsyncStorage's is a Promise. The
// Promise case is handled by callers preloading the key first — return
// null when we can't honor a synchronous read.
const v = this.localStorage.getItem(key)
return typeof v === 'string' || v === null ? v : null
}

getValue = async (key: string) => {
Expand Down Expand Up @@ -81,10 +110,12 @@ export class LocalStorage {
}

setItem = async (key: string, value: string) => {
if (this.syncCache.has(key)) this.syncCache.set(key, value)
return await this.localStorage.setItem(key, value)
}

setValue = async (key: string, value: string) => {
if (this.syncCache.has(key)) this.syncCache.set(key, value)
return await this.localStorage.setItem(key, value)
}

Expand All @@ -98,10 +129,14 @@ export class LocalStorage {
value,
expiry: Date.now() + ttlSeconds * 1000
}
if (this.syncCache.has(key)) {
this.syncCache.set(key, JSON.stringify(expiring))
}
this.localStorage.setItem(key, JSON.stringify(expiring))
}

async removeItem(key: string) {
if (this.syncCache.has(key)) this.syncCache.set(key, null)
await this.localStorage.removeItem(key)
}

Expand Down
34 changes: 33 additions & 1 deletion packages/mobile/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useState } from 'react'

import { SyncLocalStorageUserProvider } from '@audius/common/api'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { PortalProvider, PortalHost } from '@gorhom/portal'
Expand All @@ -21,7 +23,10 @@ import { RateCtaReminder } from 'app/components/rate-cta-drawer/RateCtaReminder'
import { Toasts } from 'app/components/toasts'
import { incrementSessionCount } from 'app/hooks/useSessionCount'
import { RootScreen } from 'app/screens/root-screen'
import { localStorage } from 'app/services/local-storage'
import {
localStorage,
localStoragePreloadPromise
} from 'app/services/local-storage'
import { queryClient } from 'app/services/query-client'
import { persistor, store } from 'app/store'
import { subscribeToNetworkStatusUpdates } from 'app/utils/reachability'
Expand Down Expand Up @@ -51,12 +56,39 @@ if (Platform.OS === 'android') {
// Increment the session count when the App.tsx code is first run
incrementSessionCount()

// Tracks completion of the AsyncStorage preload kicked off at module load.
// Set to true synchronously if the promise has already resolved by the time
// React boots, so we never gate when there's nothing to wait on.
let localStoragePreloaded = false
localStoragePreloadPromise.then(
() => {
localStoragePreloaded = true
},
() => {
localStoragePreloaded = true
}
)

const App = () => {
const [preloaded, setPreloaded] = useState(localStoragePreloaded)

useEffectOnce(() => {
subscribeToNetworkStatusUpdates()
TrackPlayer.setupPlayer({ autoHandleInterruptions: true })
if (!localStoragePreloaded) {
localStoragePreloadPromise.then(
() => setPreloaded(true),
() => setPreloaded(true)
)
}
})

// Wait for the cached account/user to land in the sync cache before
// rendering. Without this gate, useCurrentAccount.placeholderData returns
// null on first render and we briefly flash the sign-on screen for a
// logged-in user. Native splash stays up during this short wait.
if (!preloaded) return null

return (
<AppContextProvider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ export const ScreenSecondaryContent = (props: ScreenSecondaryContentProps) => {
const { children, skeleton } = props
const { isPrimaryContentReady } = useScreenContext()

// Note: not animating on Android because shadows are rendered natively behind the
// animated view and thus don't follow the animation.
// Skip the iOS FadeIn entrance when a skeleton is provided — the skeleton
// is the visual placeholder, so fading the swap-in causes a brief flash
// through opacity 0. Keep the FadeIn for the no-skeleton case where the
// children are appearing from nothing.
// Android: not animated because shadows render natively behind the
// animated view and don't follow the animation.
const shouldFadeIn = Platform.OS === 'ios' && !skeleton

return isPrimaryContentReady ? (
<Animated.View
entering={Platform.OS === 'ios' ? FadeIn : undefined}
entering={shouldFadeIn ? FadeIn : undefined}
style={{ flex: 1, minHeight: 0 }}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { useRef, type ReactNode } from 'react'
import { useEffect } from 'react'

import { useCurrentAccountUser, useHasAccount } from '@audius/common/api'
import { Status } from '@audius/common/models'
import type { LinkingOptions } from '@react-navigation/native'
import {
NavigationContainer as RNNavigationContainer,
createNavigationContainerRef,
getStateFromPath
} from '@react-navigation/native'
import { useAccountStatus } from '~/api/tan-query/users/account/useAccountStatus'

import { AppTabNavigationProvider } from 'app/screens/app-screen'
import { screen } from 'app/services/analytics'
Expand Down Expand Up @@ -38,30 +35,13 @@ const NavigationContainer = (props: NavigationContainerProps) => {
select: (user) => user?.handle
})
const hasAccount = useHasAccount()
const { data: accountStatus } = useAccountStatus()
const hasCompletedInitialLoad = useRef(false)

const routeNameRef = useRef<string | undefined>(undefined)

// Ensure that the user's account data is fully loaded before rendering the app.
// This prevents the NavigationContainer from rendering prematurely, which relies
// on the hasAccount state to determine how to handle deep links.
useEffect(() => {
if (
!hasCompletedInitialLoad.current &&
(accountStatus === Status.SUCCESS || accountStatus === Status.ERROR)
) {
hasCompletedInitialLoad.current = true
}
}, [accountStatus])

if (
!hasCompletedInitialLoad.current &&
(accountStatus === Status.IDLE ||
(accountStatus === Status.LOADING && !hasAccount))
) {
return null
}
// hasAccount/accountHandle come from useCurrentAccount's synchronous
// placeholderData (sourced from local storage), so getStateFromPath has
// the values it needs from the first render — no need to gate the
// navigator on the server's account-status round trip.

const linking: LinkingOptions<{}> = {
prefixes: [
Expand Down
23 changes: 16 additions & 7 deletions packages/mobile/src/harmony-native/components/Artwork.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Children, useEffect, useState } from 'react'
import { Children, useEffect, useMemo, useState } from 'react'

import { useTheme } from '@emotion/react'
import Animated, {
Expand Down Expand Up @@ -55,13 +55,22 @@ export const Artwork = (props: ArtworkProps) => {
const isLoading = isLoadingProp ?? isLoadingState
const { color, cornerRadius, motion } = useTheme()

const imageSource = !source
? source
: typeof source === 'number'
? source
// Pull primitives off the source so we can memoize on stable identity.
// Without this, every render produces a fresh source object, which
// AnimatedImage treats as a new image and reloads — visible as a flash.
const sourceNumber = typeof source === 'number' ? source : undefined
const sourceUri =
!source || typeof source === 'number'
? undefined
: Array.isArray(source)
? { uri: source[0].uri }
: { uri: source.uri }
? source[0]?.uri
: source.uri

const imageSource = useMemo(() => {
if (sourceNumber !== undefined) return sourceNumber
if (sourceUri) return { uri: sourceUri }
return undefined
}, [sourceNumber, sourceUri])

const hasImageSource = typeof imageSource === 'number' || imageSource?.uri
const hasChildren = Children.toArray(children).length > 0
Expand Down
99 changes: 43 additions & 56 deletions packages/mobile/src/screens/root-screen/RootScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from 'common/store/pages/signon/selectors'
import { Platform } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
import { useEffectOnce } from 'react-use'

import useAppState from 'app/hooks/useAppState'
import { useDrawer } from 'app/hooks/useDrawer'
Expand Down Expand Up @@ -76,7 +77,6 @@ export const RootScreen = () => {
const welcomeModalShown = useSelector(getWelcomeModalShown)
const isAndroid = Platform.OS === MobileOS.ANDROID

const [isLoaded, setIsLoaded] = useState(false)
const [isSplashScreenDismissed, setIsSplashScreenDismissed] = useState(false)
const { navigate } = useNavigation()
const { onOpen: openWelcomeDrawer } = useDrawer('Welcome')
Expand All @@ -90,21 +90,15 @@ export const RootScreen = () => {

useResetNotificationBadgeCount()

useEffect(() => {
if (
!isLoaded &&
(accountStatus === Status.SUCCESS || accountStatus === Status.ERROR)
) {
// Reset the player when the app is loaded for the first time. Fixes an issue
// where after a crash, the player would persist the previous state. PAY-1412.
dispatch(reset({ shouldAutoplay: false }))
setIsLoaded(true)
}
}, [accountStatus, setIsLoaded, isLoaded, dispatch])
// Reset the player on first mount so a crash doesn't leak previous playback
// state into the next session. PAY-1412.
useEffectOnce(() => {
dispatch(reset({ shouldAutoplay: false }))
})

// Connect to chats websockets and prefetch chats
// Connect to chats once the server confirms the account.
useEffect(() => {
if (isLoaded && accountStatus === Status.SUCCESS) {
if (accountStatus === Status.SUCCESS) {
dispatch(connect())
dispatch(fetchMoreChats())
dispatch(fetchUnreadMessagesCount())
Expand All @@ -113,7 +107,7 @@ export const RootScreen = () => {
return () => {
dispatch(disconnect())
}
}, [dispatch, isLoaded, accountStatus])
}, [dispatch, accountStatus])

const handleSplashScreenDismissed = useCallback(() => {
setIsSplashScreenDismissed(true)
Expand Down Expand Up @@ -144,50 +138,43 @@ export const RootScreen = () => {

return (
<>
<SplashScreen
canDismiss={isLoaded}
onDismiss={handleSplashScreenDismissed}
/>
<StatusBar isAppLoaded={isLoaded} />
{isLoaded ? (
<Stack.Navigator
screenOptions={{ gestureEnabled: false, headerShown: false }}
>
{updateRequired ? (
<Stack.Screen name='UpdateStack' component={UpdateRequiredScreen} />
) : null}

{showHomeStack ? (
<Stack.Screen
name='HomeStack'
component={AppDrawerScreen}
// animation: none here is a workaround to prevent "white screen of death" on Android
options={isAndroid ? { animation: 'none' } : undefined}
/>
) : (
<Stack.Screen name='SignOnStack'>
{() => (
<SignOnStack
isSplashScreenDismissed={isSplashScreenDismissed}
/>
)}
</Stack.Screen>
)}
<Stack.Screen
name='ResetPassword'
component={ResetPasswordModalScreen}
options={{ presentation: 'modal' }}
/>
<SplashScreen canDismiss onDismiss={handleSplashScreenDismissed} />
<StatusBar isAppLoaded />
<Stack.Navigator
screenOptions={{ gestureEnabled: false, headerShown: false }}
>
{updateRequired ? (
<Stack.Screen name='UpdateStack' component={UpdateRequiredScreen} />
) : null}

{showHomeStack ? (
<Stack.Screen
name='OAuthScreen'
component={OAuthScreen}
options={{ presentation: 'modal' }}
name='HomeStack'
component={AppDrawerScreen}
// animation: none here is a workaround to prevent "white screen of death" on Android
options={isAndroid ? { animation: 'none' } : undefined}
/>
<Stack.Screen name='TokenPicker' options={{ presentation: 'modal' }}>
{() => <PortalHost name='TokenPickerPortal' />}
) : (
<Stack.Screen name='SignOnStack'>
{() => (
<SignOnStack isSplashScreenDismissed={isSplashScreenDismissed} />
)}
</Stack.Screen>
</Stack.Navigator>
) : null}
)}
<Stack.Screen
name='ResetPassword'
component={ResetPasswordModalScreen}
options={{ presentation: 'modal' }}
/>
<Stack.Screen
name='OAuthScreen'
component={OAuthScreen}
options={{ presentation: 'modal' }}
/>
<Stack.Screen name='TokenPicker' options={{ presentation: 'modal' }}>
{() => <PortalHost name='TokenPickerPortal' />}
</Stack.Screen>
</Stack.Navigator>
</>
)
}
Loading
Loading