From bb09ef1aa46dbd62d1452c2dc8018108f471bf00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 22:19:55 +0000 Subject: [PATCH 1/5] Fix playlist ID deep link routing on mobile Co-authored-by: Dylan Jeffers --- packages/mobile/jest.deeplink.config.js | 15 + packages/mobile/package.json | 4 +- .../NavigationContainer.test.ts | 41 +++ .../NavigationContainer.tsx | 255 +------------- .../getNavigationStateFromDeeplinkPath.ts | 328 ++++++++++++++++++ 5 files changed, 394 insertions(+), 249 deletions(-) create mode 100644 packages/mobile/jest.deeplink.config.js create mode 100644 packages/mobile/src/components/navigation-container/NavigationContainer.test.ts create mode 100644 packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts diff --git a/packages/mobile/jest.deeplink.config.js b/packages/mobile/jest.deeplink.config.js new file mode 100644 index 00000000000..01c671d1092 --- /dev/null +++ b/packages/mobile/jest.deeplink.config.js @@ -0,0 +1,15 @@ +module.exports = { + testMatch: ['/src/components/navigation-container/**/*.test.ts'], + testEnvironment: 'node', + setupFilesAfterEnv: [], + transformIgnorePatterns: ['/node_modules/'], + moduleNameMapper: { + '^react-native$': '/__mocks__/react-native.js', + '^react-native/(.*)$': '/__mocks__/react-native.js', + '^~/(.*)$': '/src/$1', + '^app/(.*)$': '/src/$1', + '^@audius/sdk$': '/../sdk/src', + '^@audius/sdk/(.*)$': '/../sdk/src/$1' + } +} + diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6c3f2369827..adaffdbd8d1 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -217,9 +217,7 @@ }, "jest": { "preset": "react-native", - "setupFilesAfterEnv": [ - "@testing-library/jest-native/extend-expect" - ], + "setupFilesAfterEnv": ["/jest.setup.js"], "moduleFileExtensions": [ "ts", "tsx", diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts new file mode 100644 index 00000000000..f80965b3853 --- /dev/null +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts @@ -0,0 +1,41 @@ +import { getNavigationStateFromDeeplinkPath } from 'app/utils/deeplink/getNavigationStateFromDeeplinkPath' + +const stubGetStateFromPath = (path: string) => ({ routes: [{ name: path }] }) + +const getLeafRouteName = (state: any): string | undefined => { + let current: any = state + while (current?.routes?.length) { + current = current.routes[current.index ?? 0] + if (current?.state) current = current.state + } + return current?.name +} + +describe('getNavigationStateFromDeeplinkPath', () => { + test('routes /users/:id to Profile', () => { + const state = getNavigationStateFromDeeplinkPath({ + path: '/users/Nz9yBb4', + options: undefined, + hasAccount: true, + accountHandle: 'someone', + routeName: '/trending', + getStateFromPath: stubGetStateFromPath as any + }) + + expect(getLeafRouteName(state)).toBe('Profile') + }) + + test('routes /playlists/:id to Collection', () => { + const state = getNavigationStateFromDeeplinkPath({ + path: '/playlists/Nz9yBb4', + options: undefined, + hasAccount: true, + accountHandle: 'someone', + routeName: '/trending', + getStateFromPath: stubGetStateFromPath as any + }) + + expect(getLeafRouteName(state)).toBe('Collection') + }) +}) + diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx index d66cc7d8066..c8cc8e0122e 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react' import { useCurrentAccountUser, useHasAccount } from '@audius/common/api' import { Status } from '@audius/common/models' -import { OptionalHashId } from '@audius/sdk' import type { LinkingOptions, NavigationState, @@ -14,7 +13,6 @@ import { createNavigationContainerRef, getStateFromPath } from '@react-navigation/native' -import queryString from 'query-string' import { useAccountStatus } from '~/api/tan-query/users/account/useAccountStatus' import { AppTabNavigationProvider } from 'app/screens/app-screen' @@ -23,6 +21,7 @@ import { getPrimaryRoute } from 'app/utils/navigation' import { useThemeVariant } from 'app/utils/theme' import { navigationThemes } from './navigationThemes' +import { getNavigationStateFromDeeplinkPath } from '../../utils/deeplink/getNavigationStateFromDeeplinkPath' type NavigationContainerProps = { children: ReactNode @@ -258,250 +257,14 @@ const NavigationContainer = (props: NavigationContainerProps) => { }, // TODO: This should be unit tested getStateFromPath: (path, options) => { - const pathPart = (path: string) => (index: number) => { - const rawResult = path.split('/')[index] - const queryIndex = rawResult?.indexOf('?') ?? -1 - const trimmed = - queryIndex > -1 ? rawResult.slice(0, queryIndex) : rawResult - return trimmed - } - - // Add leading slash if it is missing - if (path[0] !== '/') path = `/${path}` - - // Decode URL-encoded characters in the path - try { - path = decodeURIComponent(path) - } catch (e) { - // If decoding fails, continue with the original path - console.warn('Failed to decode URL path:', path, e) - } - - path = path.replace('#embed', '') - - // OAuth authorization URLs (e.g. /oauth/authorize?...) are intercepted by - // the app via Universal Links. Route them to the OAuth screen instead of - // crashing with an unmatched path. - if (path.match(/^\/oauth\//)) { - const queryStart = path.indexOf('?') - const search = queryStart > -1 ? path.slice(queryStart) : '' - return { - routes: [{ name: 'OAuthScreen', params: { search } }] - } - } - - const connectPath = /^\/(connect)/ - if (path.match(connectPath)) { - path = `${path.replace( - connectPath, - routeNameRef.current ?? '/trending' - )}&path=connect` - } - - const walletConnectPath = /^\/(wallet-connect)/ - if (path.match(walletConnectPath)) { - path = `${path.replace( - walletConnectPath, - '/wallets' - )}&path=wallet-connect` - } - - const walletSignPath = /^\/(wallet-sign-message)/ - if (path.match(walletSignPath)) { - path = `${path.replace( - walletSignPath, - '/wallets' - )}&path=wallet-sign-message` - } - - if (path.match(`^/app-redirect`)) { - // Remove the app-redirect prefix if present - path = path.replace(`/app-redirect`, '') - } - - // Strip the trending query param because `/trending` will - // always go to ThisWeek - if (path.match(/^\/trending/)) { - path = '/trending' - } - - // Opaque ID routes - // /tracks/Nz9yBb4 - if (path.match(/^\/tracks\//)) { - const id = OptionalHashId.parse(pathPart(path)(2)) - return createTrendingStackState({ - name: 'Track', - params: { - id - } - }) - } - - // /users/Nz9yBb4 - if (path.match(/^\/users\//)) { - const id = OptionalHashId.parse(pathPart(path)(2)) - return createTrendingStackState({ - name: 'Profile', - params: { - id - } - }) - } - - // /playlists/Nz9yBb4 - if (path.match(/^\/playlists\//)) { - const id = OptionalHashId.parse(pathPart(path)(2)) - return createTrendingStackState({ - name: 'Profile', - params: { - id - } - }) - } - - if (path.match(/^\/rewards/)) { - return createTrendingStackState({ - name: 'RewardsScreen' - }) - } - - if (path.match(/^\/wallet(?:\/|\?|$)/)) { - return createTrendingStackState({ - name: 'wallet' - }) - } - - if (path.match(/^\/coins/)) { - const ticker = pathPart(path)(2) - const coinRoute = pathPart(path)(3) - const redeemCode = pathPart(path)(4) - - if (ticker && ticker !== 'create') { - // Normalize ticker to uppercase - const normalizedTicker = ticker.toUpperCase() - - if (coinRoute === 'redeem') { - return createTrendingStackState({ - name: 'CoinRedeemScreen', - params: { - ticker: normalizedTicker, - code: redeemCode ?? undefined - } - }) - } - - return createTrendingStackState({ - name: 'CoinDetailsScreen', - params: { ticker: normalizedTicker } - }) - } - - return createTrendingStackState({ - name: 'ArtistCoinsExplore' - }) - } - - // /search - if (path.match(/^\/search(?:\/|\?|$)/)) { - const { - query: { query: searchQuery, ...filters } - } = queryString.parseUrl(path) - - // Route search URLs to the explore tab with SearchExplore screen - // This ensures proper deeplinking for both search URLs and search with filters - return createExploreStackState({ - name: 'SearchExplore', - params: { - query: searchQuery, - category: pathPart(path)(2) ?? 'all', - filters, - autoFocus: false - } - }) - } - - // /explore - if (path.match(/^\/explore(?:\/|\?|$)/)) { - const { - query: { query: exploreQuery, ...filters } - } = queryString.parseUrl(path) - - // Route explore URLs to the explore tab with SearchExplore screen - // This ensures both /search and /explore URLs work for deeplinking - return createExploreStackState({ - name: 'SearchExplore', - params: { - query: exploreQuery, - category: pathPart(path)(2) ?? 'all', - filters, - autoFocus: false - } - }) - } - - const { query } = queryString.parseUrl(path) - const { login, warning } = query - - if (login && warning) { - path = queryString.stringifyUrl({ url: '/reset-password', query }) - } - - const settingsPath = /^\/(settings)/ - if (path.match(settingsPath)) { - const subpath = pathPart(path)(2) - const subpathParam = subpath != null ? `?path=${subpath}` : '' - const queryParamsStart = path.indexOf('?') - const queryParams = - queryParamsStart > -1 ? `&${path.slice(queryParamsStart + 1)}` : '' - path = `/settings${subpathParam}${queryParams}` - } else if (path.match(`^/${accountHandle}(/|$)`)) { - // If the path is the current user and set path as `/profile` - path = path.replace(`/${accountHandle}`, '/profile') - } else { - // If the path has two parts - if (path.match(/^\/[^/]+\/[^/]+$/)) { - // If the path doesn't match a profile tab, it's a track - if (!path.match(/^\/[^/]+\/(tracks|albums|playlists|reposts)$/)) { - path = `/track${path}` - } - } - - if (path.match(/^\/[^/]+\/playlist\/[^/]+$/)) { - // set the path as `collection` - path = path.replace( - /(^\/[^/]+\/)(playlist)(\/[^/]+$)/, - '$1collection$3' - ) - path = `${path}?collectionType=playlist` - } else if (path.match(/^\/[^/]+\/album\/[^/]+$/)) { - // set the path as `collection` - path = path.replace(/(^\/[^/]+\/)(album)(\/[^/]+$)/, '$1collection$3') - path = `${path}?collectionType=album` - } - } - - if (!hasAccount && !path.match(/^\/reset-password/)) { - // Redirect to sign in with original path in query params - const { url, query } = queryString.parseUrl(path) - - // If url is signin or signup, set screen param instead of routeOnCompletion - if (url === '/signin' || url === '/signup') { - path = queryString.stringifyUrl({ - url: '/sign-on', - query: { - ...query, - screen: url === '/signin' ? 'sign-in' : 'sign-up' - } - }) - } else { - path = queryString.stringifyUrl({ - url: '/sign-on', - query: { ...query, screen: 'sign-up', routeOnCompletion: url } - }) - } - } - - return getStateFromPath(path, options) + return getNavigationStateFromDeeplinkPath({ + path, + options, + hasAccount, + accountHandle, + routeName: routeNameRef.current, + getStateFromPath + }) } } diff --git a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts new file mode 100644 index 00000000000..9ab59deb164 --- /dev/null +++ b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts @@ -0,0 +1,328 @@ +import queryString from 'query-string' + +const parseOptionalHashId = (raw: string | undefined) => { + return raw == null || raw === '' ? null : raw +} + +type GetStateFromPath = ( + path: string, + options: any +) => any + +type GetNavigationStateFromDeeplinkPathArgs = { + path: string + options: any + hasAccount: boolean + accountHandle?: string + routeName?: string + getStateFromPath: GetStateFromPath +} + +const createAppTabState = ( + state: any +): any => ({ + routes: [ + { + name: 'HomeStack', + state: { + routes: [ + { + name: 'App', + state: { + routes: [ + { + name: 'AppTabs', + state + } + ] + } + } + ] + } + } + ] +}) + +const createTrendingStackState = (route): any => + createAppTabState({ + routes: [ + { + name: 'trending', + state: { + index: 1, + routes: [ + { + name: 'Trending' + }, + route + ] + } + } + ] + }) + +const createExploreStackState = (route): any => + createAppTabState({ + routes: [ + { + name: 'explore', + state: { + index: 1, + routes: [ + { + name: 'Explore' + }, + route + ] + } + } + ] + }) + +export const getNavigationStateFromDeeplinkPath = ({ + path, + options, + hasAccount, + accountHandle, + routeName, + getStateFromPath +}: GetNavigationStateFromDeeplinkPathArgs) => { + const pathPart = (path: string) => (index: number) => { + const rawResult = path.split('/')[index] + const queryIndex = rawResult?.indexOf('?') ?? -1 + const trimmed = queryIndex > -1 ? rawResult.slice(0, queryIndex) : rawResult + return trimmed + } + + // Add leading slash if it is missing + if (path[0] !== '/') path = `/${path}` + + // Decode URL-encoded characters in the path + try { + path = decodeURIComponent(path) + } catch (e) { + // If decoding fails, continue with the original path + console.warn('Failed to decode URL path:', path, e) + } + + path = path.replace('#embed', '') + + // OAuth authorization URLs (e.g. /oauth/authorize?...) are intercepted by + // the app via Universal Links. Route them to the OAuth screen instead of + // crashing with an unmatched path. + if (path.match(/^\/oauth\//)) { + const queryStart = path.indexOf('?') + const search = queryStart > -1 ? path.slice(queryStart) : '' + return { + routes: [{ name: 'OAuthScreen', params: { search } }] + } + } + + const connectPath = /^\/(connect)/ + if (path.match(connectPath)) { + path = `${path.replace(connectPath, routeName ?? '/trending')}&path=connect` + } + + const walletConnectPath = /^\/(wallet-connect)/ + if (path.match(walletConnectPath)) { + path = `${path.replace(walletConnectPath, '/wallets')}&path=wallet-connect` + } + + const walletSignPath = /^\/(wallet-sign-message)/ + if (path.match(walletSignPath)) { + path = `${path.replace( + walletSignPath, + '/wallets' + )}&path=wallet-sign-message` + } + + if (path.match(`^/app-redirect`)) { + // Remove the app-redirect prefix if present + path = path.replace(`/app-redirect`, '') + } + + // Strip the trending query param because `/trending` will + // always go to ThisWeek + if (path.match(/^\/trending/)) { + path = '/trending' + } + + // Opaque ID routes + // /tracks/Nz9yBb4 + if (path.match(/^\/tracks\//)) { + const id = parseOptionalHashId(pathPart(path)(2)) + return createTrendingStackState({ + name: 'Track', + params: { + id + } + }) + } + + // /users/Nz9yBb4 + if (path.match(/^\/users\//)) { + const id = parseOptionalHashId(pathPart(path)(2)) + return createTrendingStackState({ + name: 'Profile', + params: { + id + } + }) + } + + // /playlists/Nz9yBb4 + if (path.match(/^\/playlists\//)) { + const id = parseOptionalHashId(pathPart(path)(2)) + return createTrendingStackState({ + name: 'Collection', + params: { + id + } + }) + } + + if (path.match(/^\/rewards/)) { + return createTrendingStackState({ + name: 'RewardsScreen' + }) + } + + if (path.match(/^\/wallet(?:\/|\?|$)/)) { + return createTrendingStackState({ + name: 'wallet' + }) + } + + if (path.match(/^\/coins/)) { + const ticker = pathPart(path)(2) + const coinRoute = pathPart(path)(3) + const redeemCode = pathPart(path)(4) + + if (ticker && ticker !== 'create') { + // Normalize ticker to uppercase + const normalizedTicker = ticker.toUpperCase() + + if (coinRoute === 'redeem') { + return createTrendingStackState({ + name: 'CoinRedeemScreen', + params: { + ticker: normalizedTicker, + code: redeemCode ?? undefined + } + }) + } + + return createTrendingStackState({ + name: 'CoinDetailsScreen', + params: { ticker: normalizedTicker } + }) + } + + return createTrendingStackState({ + name: 'ArtistCoinsExplore' + }) + } + + // /search + if (path.match(/^\/search(?:\/|\?|$)/)) { + const { + query: { query: searchQuery, ...filters } + } = queryString.parseUrl(path) + + // Route search URLs to the explore tab with SearchExplore screen + // This ensures proper deeplinking for both search URLs and search with filters + return createExploreStackState({ + name: 'SearchExplore', + params: { + query: searchQuery, + category: pathPart(path)(2) ?? 'all', + filters, + autoFocus: false + } + }) + } + + // /explore + if (path.match(/^\/explore(?:\/|\?|$)/)) { + const { + query: { query: exploreQuery, ...filters } + } = queryString.parseUrl(path) + + // Route explore URLs to the explore tab with SearchExplore screen + // This ensures both /search and /explore URLs work for deeplinking + return createExploreStackState({ + name: 'SearchExplore', + params: { + query: exploreQuery, + category: pathPart(path)(2) ?? 'all', + filters, + autoFocus: false + } + }) + } + + const { query } = queryString.parseUrl(path) + const { login, warning } = query + + if (login && warning) { + path = queryString.stringifyUrl({ url: '/reset-password', query }) + } + + const settingsPath = /^\/(settings)/ + if (path.match(settingsPath)) { + const subpath = pathPart(path)(2) + const subpathParam = subpath != null ? `?path=${subpath}` : '' + const queryParamsStart = path.indexOf('?') + const queryParams = + queryParamsStart > -1 ? `&${path.slice(queryParamsStart + 1)}` : '' + path = `/settings${subpathParam}${queryParams}` + } else if (path.match(`^/${accountHandle}(/|$)`)) { + // If the path is the current user and set path as `/profile` + path = path.replace(`/${accountHandle}`, '/profile') + } else { + // If the path has two parts + if (path.match(/^\/[^/]+\/[^/]+$/)) { + // If the path doesn't match a profile tab, it's a track + if (!path.match(/^\/[^/]+\/(tracks|albums|playlists|reposts)$/)) { + path = `/track${path}` + } + } + + if (path.match(/^\/[^/]+\/playlist\/[^/]+$/)) { + // set the path as `collection` + path = path.replace( + /(^\/[^/]+\/)(playlist)(\/[^/]+$)/, + '$1collection$3' + ) + path = `${path}?collectionType=playlist` + } else if (path.match(/^\/[^/]+\/album\/[^/]+$/)) { + // set the path as `collection` + path = path.replace(/(^\/[^/]+\/)(album)(\/[^/]+$)/, '$1collection$3') + path = `${path}?collectionType=album` + } + } + + if (!hasAccount && !path.match(/^\/reset-password/)) { + // Redirect to sign in with original path in query params + const { url, query } = queryString.parseUrl(path) + + // If url is signin or signup, set screen param instead of routeOnCompletion + if (url === '/signin' || url === '/signup') { + path = queryString.stringifyUrl({ + url: '/sign-on', + query: { + ...query, + screen: url === '/signin' ? 'sign-in' : 'sign-up' + } + }) + } else { + path = queryString.stringifyUrl({ + url: '/sign-on', + query: { ...query, screen: 'sign-up', routeOnCompletion: url } + }) + } + } + + return getStateFromPath(path, options) +} + From f6d401e9edea046aba7b5c21103b04752ec0a6f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 23:22:53 +0000 Subject: [PATCH 2/5] Fix current-user permalink deeplink rewrite Co-authored-by: Dylan Jeffers --- .../NavigationContainer.test.ts | 22 ++++++++++++++++++- .../getNavigationStateFromDeeplinkPath.ts | 15 ++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts index f80965b3853..70771efaafd 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts @@ -1,6 +1,13 @@ import { getNavigationStateFromDeeplinkPath } from 'app/utils/deeplink/getNavigationStateFromDeeplinkPath' -const stubGetStateFromPath = (path: string) => ({ routes: [{ name: path }] }) +const stubGetStateFromPath = (path: string) => + path.startsWith('/track/') + ? { routes: [{ name: 'Track' }] } + : path.startsWith('/profile') + ? { routes: [{ name: 'UserProfile' }] } + : path.includes('/collection/') + ? { routes: [{ name: 'Collection' }] } + : { routes: [{ name: path }] } const getLeafRouteName = (state: any): string | undefined => { let current: any = state @@ -37,5 +44,18 @@ describe('getNavigationStateFromDeeplinkPath', () => { expect(getLeafRouteName(state)).toBe('Collection') }) + + test('does not rewrite current user playlist permalink to /profile', () => { + const state = getNavigationStateFromDeeplinkPath({ + path: '/Audius/playlist/140', + options: undefined, + hasAccount: true, + accountHandle: 'Audius', + routeName: '/trending', + getStateFromPath: stubGetStateFromPath as any + }) + + expect(getLeafRouteName(state)).toBe('Collection') + }) }) diff --git a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts index 9ab59deb164..7696a794cad 100644 --- a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts +++ b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts @@ -18,6 +18,15 @@ type GetNavigationStateFromDeeplinkPathArgs = { getStateFromPath: GetStateFromPath } +const isProfilePathForHandle = (path: string, handle: string) => { + const normalizedHandle = handle.replace(/^@/, '') + const escaped = normalizedHandle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return ( + path.match(new RegExp(`^/${escaped}$`, 'i')) || + path.match(new RegExp(`^/${escaped}/(tracks|albums|playlists|reposts)$`, 'i')) + ) +} + const createAppTabState = ( state: any ): any => ({ @@ -276,9 +285,9 @@ export const getNavigationStateFromDeeplinkPath = ({ const queryParams = queryParamsStart > -1 ? `&${path.slice(queryParamsStart + 1)}` : '' path = `/settings${subpathParam}${queryParams}` - } else if (path.match(`^/${accountHandle}(/|$)`)) { - // If the path is the current user and set path as `/profile` - path = path.replace(`/${accountHandle}`, '/profile') + } else if (accountHandle && isProfilePathForHandle(path, accountHandle)) { + // If the path is explicitly a profile URL for the current user, rewrite to `/profile` + path = path.replace(new RegExp(`^/${accountHandle.replace(/^@/, '')}`, 'i'), '/profile') } else { // If the path has two parts if (path.match(/^\/[^/]+\/[^/]+$/)) { From caffd49810dbe13898fc9b6d38a76c34676eb96b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 17:30:56 +0000 Subject: [PATCH 3/5] Add missing mobile Jest setup file Co-authored-by: Dylan Jeffers --- packages/mobile/jest.setup.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/mobile/jest.setup.js diff --git a/packages/mobile/jest.setup.js b/packages/mobile/jest.setup.js new file mode 100644 index 00000000000..206639e9090 --- /dev/null +++ b/packages/mobile/jest.setup.js @@ -0,0 +1 @@ +require('@testing-library/jest-native/extend-expect') From 1cd4b8c338633e86a4c459e412bb2c650975e8c1 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 10 Apr 2026 10:38:14 -0700 Subject: [PATCH 4/5] Fix the rest of the tests --- packages/mobile/babel.config.js | 1 + packages/mobile/package.json | 7 +++++++ .../components/Text/Text.test.tsx | 19 +++++++++++++------ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js index 887d3d5f4d5..89479a867af 100644 --- a/packages/mobile/babel.config.js +++ b/packages/mobile/babel.config.js @@ -10,6 +10,7 @@ module.exports = (api) => { extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], root: ['.'], alias: { + app: './src', '@audius/common/adapters': '../common/src/adapters', '@audius/common/messages': '../common/src/messages', '@audius/common/hooks': '../common/src/hooks', diff --git a/packages/mobile/package.json b/packages/mobile/package.json index adaffdbd8d1..50076a1e703 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -218,6 +218,13 @@ "jest": { "preset": "react-native", "setupFilesAfterEnv": ["/jest.setup.js"], + "moduleNameMapper": { + "^react-native$": "/node_modules/react-native", + "^@testing-library/react-native$": "/node_modules/@testing-library/react-native" + }, + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?|react-native-reanimated)/)" + ], "moduleFileExtensions": [ "ts", "tsx", diff --git a/packages/mobile/src/harmony-native/components/Text/Text.test.tsx b/packages/mobile/src/harmony-native/components/Text/Text.test.tsx index 6d08ae3db6f..684dd011bb4 100644 --- a/packages/mobile/src/harmony-native/components/Text/Text.test.tsx +++ b/packages/mobile/src/harmony-native/components/Text/Text.test.tsx @@ -1,15 +1,22 @@ +import type { ReactElement } from 'react' + import { render, screen } from '@testing-library/react-native' +import { ThemeProvider } from '../../foundations/theme' + import { Text } from './Text' +const renderWithTheme = (ui: ReactElement) => + render({ui}) + test('renders text correctly', () => { - render(hello world) + renderWithTheme(hello world) expect(screen.getByText(/hello world/i)).toBeOnTheScreen() }) test('it renders display variant correctly', () => { - render(test display) + renderWithTheme(test display) expect( screen.getByRole('heading', { name: /test display/i }) @@ -17,7 +24,7 @@ test('it renders display variant correctly', () => { }) test('it renders heading variant correctly', () => { - render(test heading) + renderWithTheme(test heading) expect( screen.getByRole('heading', { name: /test heading/i }) @@ -25,7 +32,7 @@ test('it renders heading variant correctly', () => { }) test('it renders labels correctly', () => { - render(test label) + renderWithTheme(test label) expect(screen.getByText(/test label/i)).toHaveStyle({ textTransform: 'uppercase' @@ -33,9 +40,9 @@ test('it renders labels correctly', () => { }) test('it renders color correctly', () => { - render(test label) + renderWithTheme(test label) expect(screen.getByText(/test label/i)).toHaveStyle({ - color: '#C2C0CC' + color: '#A2A0AFFF' }) }) From b8ac815f62e2daca43f63cc810cc810652a41a0b Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 10 Apr 2026 10:39:44 -0700 Subject: [PATCH 5/5] Fixes --- .../NavigationContainer.test.ts | 1 - .../NavigationContainer.tsx | 70 +------------------ .../getNavigationStateFromDeeplinkPath.ts | 24 +++---- 3 files changed, 13 insertions(+), 82 deletions(-) diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts index 70771efaafd..5e0ac28cfbc 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts @@ -58,4 +58,3 @@ describe('getNavigationStateFromDeeplinkPath', () => { expect(getLeafRouteName(state)).toBe('Collection') }) }) - diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx index c8cc8e0122e..7b34196c9ec 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx @@ -3,11 +3,7 @@ import { useEffect } from 'react' import { useCurrentAccountUser, useHasAccount } from '@audius/common/api' import { Status } from '@audius/common/models' -import type { - LinkingOptions, - NavigationState, - PartialState -} from '@react-navigation/native' +import type { LinkingOptions } from '@react-navigation/native' import { NavigationContainer as RNNavigationContainer, createNavigationContainerRef, @@ -20,9 +16,10 @@ import { screen } from 'app/services/analytics' import { getPrimaryRoute } from 'app/utils/navigation' import { useThemeVariant } from 'app/utils/theme' -import { navigationThemes } from './navigationThemes' import { getNavigationStateFromDeeplinkPath } from '../../utils/deeplink/getNavigationStateFromDeeplinkPath' +import { navigationThemes } from './navigationThemes' + type NavigationContainerProps = { children: ReactNode navigationIntegration: any // Sentry removed - kept for compatibility @@ -30,67 +27,6 @@ type NavigationContainerProps = { export const navigationRef = createNavigationContainerRef() -const createAppTabState = ( - state: PartialState -): PartialState => ({ - routes: [ - { - name: 'HomeStack', - state: { - routes: [ - { - name: 'App', - state: { - routes: [ - { - name: 'AppTabs', - state - } - ] - } - } - ] - } - } - ] -}) - -const createTrendingStackState = (route): PartialState => - createAppTabState({ - routes: [ - { - name: 'trending', - state: { - index: 1, - routes: [ - { - name: 'Trending' - }, - route - ] - } - } - ] - }) - -const createExploreStackState = (route): PartialState => - createAppTabState({ - routes: [ - { - name: 'explore', - state: { - index: 1, - routes: [ - { - name: 'Explore' - }, - route - ] - } - } - ] - }) - /** * NavigationContainer contains the react-navigation context * and configures linking diff --git a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts index 7696a794cad..b3395828b27 100644 --- a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts +++ b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts @@ -4,10 +4,7 @@ const parseOptionalHashId = (raw: string | undefined) => { return raw == null || raw === '' ? null : raw } -type GetStateFromPath = ( - path: string, - options: any -) => any +type GetStateFromPath = (path: string, options: any) => any type GetNavigationStateFromDeeplinkPathArgs = { path: string @@ -23,13 +20,13 @@ const isProfilePathForHandle = (path: string, handle: string) => { const escaped = normalizedHandle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return ( path.match(new RegExp(`^/${escaped}$`, 'i')) || - path.match(new RegExp(`^/${escaped}/(tracks|albums|playlists|reposts)$`, 'i')) + path.match( + new RegExp(`^/${escaped}/(tracks|albums|playlists|reposts)$`, 'i') + ) ) } -const createAppTabState = ( - state: any -): any => ({ +const createAppTabState = (state: any): any => ({ routes: [ { name: 'HomeStack', @@ -287,7 +284,10 @@ export const getNavigationStateFromDeeplinkPath = ({ path = `/settings${subpathParam}${queryParams}` } else if (accountHandle && isProfilePathForHandle(path, accountHandle)) { // If the path is explicitly a profile URL for the current user, rewrite to `/profile` - path = path.replace(new RegExp(`^/${accountHandle.replace(/^@/, '')}`, 'i'), '/profile') + path = path.replace( + new RegExp(`^/${accountHandle.replace(/^@/, '')}`, 'i'), + '/profile' + ) } else { // If the path has two parts if (path.match(/^\/[^/]+\/[^/]+$/)) { @@ -299,10 +299,7 @@ export const getNavigationStateFromDeeplinkPath = ({ if (path.match(/^\/[^/]+\/playlist\/[^/]+$/)) { // set the path as `collection` - path = path.replace( - /(^\/[^/]+\/)(playlist)(\/[^/]+$)/, - '$1collection$3' - ) + path = path.replace(/(^\/[^/]+\/)(playlist)(\/[^/]+$)/, '$1collection$3') path = `${path}?collectionType=playlist` } else if (path.match(/^\/[^/]+\/album\/[^/]+$/)) { // set the path as `collection` @@ -334,4 +331,3 @@ export const getNavigationStateFromDeeplinkPath = ({ return getStateFromPath(path, options) } -