Fix navigation context issue

This commit is contained in:
Eric Bailey
2026-01-22 12:27:20 -06:00
parent 4245f71cd8
commit bec2e802f8
9 changed files with 119 additions and 163 deletions
+36 -113
View File
@@ -1,4 +1,4 @@
import {type JSX, useCallback, useEffect, useRef, useState} from 'react' import {type JSX, useCallback, useRef} from 'react'
import {Linking} from 'react-native' import {Linking} from 'react-native'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core' import {i18n, type MessageDescriptor} from '@lingui/core'
@@ -14,9 +14,7 @@ import {
DefaultTheme, DefaultTheme,
type LinkingOptions, type LinkingOptions,
NavigationContainer, NavigationContainer,
type NavigationState,
StackActions, StackActions,
useNavigation,
} from '@react-navigation/native' } from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout' import {timeout} from '#/lib/async/timeout'
@@ -138,12 +136,8 @@ import {
EmailDialogScreenID, EmailDialogScreenID,
useEmailDialogControl, useEmailDialogControl,
} from '#/components/dialogs/EmailDialog' } from '#/components/dialogs/EmailDialog'
import { import {useAnalytics} from '#/analytics'
AnalyticsContext, import {setNavigationMetadata} from '#/analytics/metadata'
type AnalyticsContextType,
useAnalytics,
utils,
} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -892,7 +886,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const {currentAccount, accounts} = useSession() const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher() const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const prevLoggedRouteName = useRef<string | undefined>(undefined) const previousScreen = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl() const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
@@ -954,16 +948,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const payload = getNotificationPayload(response.notification) const payload = getNotificationPayload(response.notification)
if (payload) { if (payload) {
ax.metric( ax.metric('notifications:openApp', {
'notifications:openApp', reason: payload.reason,
{reason: payload.reason, causedBoot: true}, causedBoot: true,
{ })
navigation: {
previousScreen: prevLoggedRouteName.current,
currentScreen: getCurrentRouteName(),
},
},
)
if (payload.reason === 'chat-message') { if (payload.reason === 'chat-message') {
handleChatMessage(payload) handleChatMessage(payload)
@@ -988,19 +976,16 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
} }
const onNavigationReady = useCallOnce(() => { const onNavigationReady = useCallOnce(() => {
prevLoggedRouteName.current = getCurrentRouteName() const currentScreen = getCurrentRouteName()
setNavigationMetadata({
previousScreen: currentScreen,
currentScreen,
})
previousScreen.current = currentScreen
handlePushNotificationEntry() handlePushNotificationEntry()
ax.metric( ax.metric('router:navigate', {})
'router:navigate',
{},
{
navigation: {
previousScreen: prevLoggedRouteName.current,
currentScreen: getCurrentRouteName(),
},
},
)
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
emailDialogControl.open({ emailDialogControl.open({
@@ -1009,39 +994,21 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
snoozeEmailConfirmationPrompt() snoozeEmailConfirmationPrompt()
} }
ax.metric( ax.metric('init', {
'init', initMs: Math.round(
{ // @ts-ignore Emitted by Metro in the bundle prelude
initMs: Math.round( performance.now() - global.__BUNDLE_START_TIME__,
// @ts-ignore Emitted by Metro in the bundle prelude ),
performance.now() - global.__BUNDLE_START_TIME__, })
),
},
{
navigation: {
previousScreen: prevLoggedRouteName.current,
currentScreen: getCurrentRouteName(),
},
},
)
if (IS_WEB) { if (IS_WEB) {
const referrerInfo = Referrer.getReferrerInfo() const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric( ax.metric('deepLink:referrerReceived', {
'deepLink:referrerReceived', to: window.location.href,
{ referrer: referrerInfo?.referrer,
to: window.location.href, hostname: referrerInfo?.hostname,
referrer: referrerInfo?.referrer, })
hostname: referrerInfo?.hostname,
},
{
navigation: {
previousScreen: prevLoggedRouteName.current,
currentScreen: getCurrentRouteName(),
},
},
)
} }
} }
}) })
@@ -1052,17 +1019,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING} linking={LINKING}
theme={theme} theme={theme}
onStateChange={() => { onStateChange={() => {
ax.metric( const currentScreen = getCurrentRouteName()
'router:navigate', // do this before metric
{from: prevLoggedRouteName.current}, setNavigationMetadata({
{ previousScreen: previousScreen.current,
navigation: { currentScreen,
previousScreen: prevLoggedRouteName.current, })
currentScreen: getCurrentRouteName(), ax.metric('router:navigate', {from: previousScreen.current})
}, previousScreen.current = currentScreen
},
)
prevLoggedRouteName.current = getCurrentRouteName()
}} }}
onReady={onNavigationReady} onReady={onNavigationReady}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
@@ -1072,49 +1036,8 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// -sfn // -sfn
navigationInChildEnabled> navigationInChildEnabled>
<NavigationAnalyticsContext>{children}</NavigationAnalyticsContext>
</NavigationContainer>
)
}
function getActiveRouteFromNavigationState(state?: NavigationState) {
if (!state) return undefined
const currentRoute = state?.routes[state.index]
return currentRoute.name
}
function NavigationAnalyticsContext({children}: {children: React.ReactNode}) {
const nav = useNavigation()
const [previousScreen, setPreviousScreen] = useState<string | undefined>(
() => getActiveRouteFromNavigationState(nav.getState()) ?? 'Home',
)
const [metadata, setMetadata] = useState<
Pick<AnalyticsContextType['metadata'], 'navigation'>
>(() => {
return {
navigation: {
previousScreen,
currentScreen: previousScreen,
},
}
})
useEffect(() => {
return nav.addListener('state', payload => {
const curr =
getActiveRouteFromNavigationState(payload.data.state) ?? 'Home'
setMetadata({
navigation: {
previousScreen,
currentScreen: curr,
},
})
setPreviousScreen(curr)
})
}, [nav, previousScreen])
return (
<AnalyticsContext metadata={utils.useMeta(metadata)}>
{children} {children}
</AnalyticsContext> </NavigationContainer>
) )
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import {GrowthBook} from '@growthbook/growthbook-react' import {GrowthBook} from '@growthbook/growthbook-react'
import {type Metadata} from '#/analytics/types' import {type Metadata} from '#/analytics/metadata'
import * as env from '#/env' import * as env from '#/env'
export {Features} from '#/analytics/features/types' export {Features} from '#/analytics/features/types'
+9 -2
View File
@@ -15,9 +15,13 @@ import {
getInitialSessionId, getInitialSessionId,
useSessionId, useSessionId,
} from '#/analytics/identifiers' } from '#/analytics/identifiers'
import {
getNavigationMetadata,
type MergeableMetadata,
type Metadata,
} from '#/analytics/metadata'
import {type Metrics, metrics} from '#/analytics/metrics' import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams' import * as refParams from '#/analytics/misc/refParams'
import {type MergeableMetadata, type Metadata} from '#/analytics/types'
import {getMetadataForLogger} from '#/analytics/utils' import {getMetadataForLogger} from '#/analytics/utils'
import * as env from '#/env' import * as env from '#/env'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
@@ -86,7 +90,10 @@ const Context = createContext<AnalyticsBaseContextType>({
if (metadata && '__meta' in metadata) { if (metadata && '__meta' in metadata) {
delete metadata.__meta delete metadata.__meta
} }
metrics.track(event, payload, metadata) metrics.track(event, payload, {
...metadata,
navigation: getNavigationMetadata(),
})
}, },
metadata: { metadata: {
base: { base: {
+61
View File
@@ -0,0 +1,61 @@
import {type Geolocation} from '#/geolocation'
export type BaseMetadata = {
deviceId: string
sessionId: string
platform: string
appVersion: string
bundleIdentifier: string
bundleDate: number
referrerSrc: string
referrerUrl: string
}
export type GeolocationMetadata = Geolocation
export type SessionMetadata = {
did: string
isBskyPds: boolean
}
export type PreferencesMetadata = {
appLanguage: string
contentLanguages: string[]
}
export type MergeableMetadata = {
session?: SessionMetadata
preferences?: PreferencesMetadata
/**
* Navigation metadata is not actually available on this object, instead it's
* merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details.
*/
navigation?: NavigationMetadata
}
export type Metadata = {
base: BaseMetadata
geolocation: GeolocationMetadata
} & MergeableMetadata
/*
* Navigation metadata is handle out-of-band from React, since we don't want to
* slow down screen transitions in any way, and there doesn't seem to be a nice
* way to get current navigation state without an additional re-render between
* navigations.
*
* So instead of this data being available on the Metadata object, it's stored
* here and merged in at time-of-log/metric.
*/
export type NavigationMetadata = {
previousScreen?: string
currentScreen?: string
}
let navigationMetadata: NavigationMetadata | undefined
export function getNavigationMetadata() {
console.log('metadata', JSON.stringify(navigationMetadata, null, 2))
return navigationMetadata
}
export function setNavigationMetadata(meta: NavigationMetadata | undefined) {
navigationMetadata = meta
}
+1 -1
View File
@@ -52,7 +52,7 @@ export class MetricsClient<M extends Record<string, any>> {
logger.info(`event: ${event as string}`, { logger.info(`event: ${event as string}`, {
payload, payload,
metadata, __metadata__: metadata, // special logger field
}) })
if (this.queue.length > this.maxBatchSize) { if (this.queue.length > this.maxBatchSize) {
-40
View File
@@ -1,40 +0,0 @@
import {type Geolocation} from '#/geolocation'
export type BaseMetadata = {
deviceId: string
sessionId: string
platform: string
appVersion: string
bundleIdentifier: string
bundleDate: number
referrerSrc: string
referrerUrl: string
}
export type GeolocationMetadata = Geolocation
export type NavigationMetadata = {
previousScreen?: string
currentScreen?: string
}
export type SessionMetadata = {
did: string
isBskyPds: boolean
}
export type PreferencesMetadata = {
appLanguage: string
contentLanguages: string[]
}
export type MergeableMetadata = {
navigation?: NavigationMetadata
session?: SessionMetadata
preferences?: PreferencesMetadata
}
export type Metadata = {
base: BaseMetadata
geolocation: GeolocationMetadata
} & MergeableMetadata
+2 -2
View File
@@ -6,7 +6,7 @@ import {
type MergeableMetadata, type MergeableMetadata,
type Metadata, type Metadata,
type SessionMetadata, type SessionMetadata,
} from '#/analytics/types' } from '#/analytics/metadata'
/** /**
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a * Thin `useMemo` wrapper that marks the metadata as memoized and provides a
@@ -14,9 +14,9 @@ import {
*/ */
export function useMeta(metadata?: MergeableMetadata) { export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata]) const m = useMemo(() => metadata, [metadata])
if (!m) return
// @ts-ignore // @ts-ignore
m.__meta = true m.__meta = true
console.log('useMeta', JSON.stringify(m, null, 2))
return m return m
} }
+4 -4
View File
@@ -33,7 +33,7 @@ export class Logger {
level: LogLevel level: LogLevel
context: LogContext | undefined = undefined context: LogContext | undefined = undefined
contextFilter: string = '' contextFilter: string = ''
inheritedMetadata: Record<string, unknown> = {} ambientMetadata: Record<string, unknown> = {}
protected debugContextRegexes: RegExp[] = [] protected debugContextRegexes: RegExp[] = []
protected transports: Transport[] = [] protected transports: Transport[] = []
@@ -55,7 +55,7 @@ export class Logger {
level, level,
context, context,
contextFilter, contextFilter,
metadata: inheritedMetadata = {}, metadata: ambientMetadata = {},
}: { }: {
level?: LogLevel level?: LogLevel
context?: LogContext context?: LogContext
@@ -65,7 +65,7 @@ export class Logger {
this.context = context this.context = context
this.level = level || LogLevel.Info this.level = level || LogLevel.Info
this.contextFilter = contextFilter || '' this.contextFilter = contextFilter || ''
this.inheritedMetadata = inheritedMetadata this.ambientMetadata = ambientMetadata
if (this.contextFilter) { if (this.contextFilter) {
this.level = LogLevel.Debug this.level = LogLevel.Debug
} }
@@ -122,8 +122,8 @@ export class Logger {
const timestamp = Date.now() const timestamp = Date.now()
const meta: Metadata = { const meta: Metadata = {
__metadata__: this.ambientMetadata,
...metadata, ...metadata,
metadata: this.inheritedMetadata,
} }
// send every log to syslog // send every log to syslog
+5
View File
@@ -49,6 +49,11 @@ export type Metadata = {
*/ */
__context__?: undefined __context__?: undefined
/**
* Reserved for inherited metadata gathered in ambient context
*/
__metadata__?: Record<string, unknown>
/** /**
* Applied as Sentry breadcrumb types. Defaults to `default`. * Applied as Sentry breadcrumb types. Defaults to `default`.
* *