exclude expo notifications from web bundle

This commit is contained in:
Samuel Newman
2026-05-25 19:08:19 +03:00
parent ad0df3cf43
commit 28ab0ba9cd
7 changed files with 236 additions and 6 deletions
+20
View File
@@ -277,6 +277,11 @@ export default defineConfig(
message:
'React is already in the global type namespace. Use named imports for runtime modules.',
},
{
name: 'expo-notifications',
message:
'Import the helpers from #/lib/notifications/expo-helpers (or the hooks from #/lib/notifications/notifications) instead. expo-notifications is stubbed on web; importing it directly pulls ~70KB of dead weight into the web bundle.',
},
],
},
],
@@ -312,6 +317,21 @@ export default defineConfig(
},
},
/**
* Native-only files that legitimately wrap expo-notifications. The .web.ts
* counterparts to these files don't import the package.
*/
{
files: [
'src/lib/notifications/notifications.ts',
'src/lib/notifications/expo-helpers.ts',
'src/lib/hooks/useNotificationHandler.ts',
],
rules: {
'no-restricted-imports': 'off',
},
},
/**
* Test files configuration
*/
+6 -3
View File
@@ -1,6 +1,5 @@
import {type JSX, useCallback, useRef} from 'react'
import * as Linking from 'expo-linking'
import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {
@@ -29,6 +28,10 @@ import {
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {
clearLastNotificationResponse,
getLastNotificationResponse,
} from '#/lib/notifications/expo-helpers'
import {useCallOnce} from '#/lib/once'
import {buildStateObject} from '#/lib/routes/helpers'
import {
@@ -976,7 +979,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
// intent urls are handled by `useIntentHandler`
if (linkingUrl) return
const notificationResponse = Notifications.getLastNotificationResponse()
const notificationResponse = getLastNotificationResponse()
if (notificationResponse) {
notyLogger.debug(`handlePushNotificationEntry: response`, {
@@ -985,7 +988,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
// Clear the last notification response to ensure it's not used again
try {
Notifications.clearLastNotificationResponse()
clearLastNotificationResponse()
} catch (error) {
notyLogger.error(
`handlePushNotificationEntry: error clearing notification response`,
+122
View File
@@ -0,0 +1,122 @@
// Web shim for #/lib/hooks/useNotificationHandler. expo-notifications doesn't
// run on web, so the hook is a no-op and the helper functions only deal with
// pure-data payloads we receive from elsewhere.
//
// Keeping this file expo-notifications-free saves ~70KB on web.
import {AtUri} from '@atproto/api'
export type NotificationReason =
| 'like'
| 'repost'
| 'follow'
| 'mention'
| 'reply'
| 'quote'
| 'chat-message'
| 'chat-reaction'
| 'chat-added-to-group'
| 'chat-removed-from-group'
| 'chat-join-request-rejected'
| 'starterpack-joined'
| 'like-via-repost'
| 'repost-via-repost'
| 'verified'
| 'unverified'
| 'subscribed-post'
type ChatNotificationReason = Extract<NotificationReason, `chat-${string}`>
export type NotificationPayload =
| undefined
| {
reason: Exclude<NotificationReason, ChatNotificationReason>
uri: string
subject: string
recipientDid: string
}
| {
reason: 'chat-message'
convoId: string
messageId: string
recipientDid: string
}
| {
reason: 'chat-reaction'
convoId: string
messageId: string
recipientDid: string
}
| {
reason:
| 'chat-added-to-group'
| 'chat-removed-from-group'
| 'chat-join-request-rejected'
convoId: string
recipientDid: string
}
export type ChatNotificationPayload = Extract<
NonNullable<NotificationPayload>,
{reason: ChatNotificationReason}
>
export function isChatNotificationPayload(
payload: NonNullable<NotificationPayload>,
): payload is ChatNotificationPayload {
return payload.reason.startsWith('chat-')
}
export function useNotificationsHandler() {}
export function storePayloadForAccountSwitch(_payload: NotificationPayload) {}
// On web there's no expo-notifications object to inspect; callers from web
// code paths shouldn't invoke this, but we keep a stub that returns null.
export function getNotificationPayload(
_e: unknown,
): NotificationPayload | null {
return null
}
export function notificationToURL(payload: NotificationPayload): string | null {
switch (payload?.reason) {
case 'like':
case 'repost':
case 'like-via-repost':
case 'repost-via-repost': {
const urip = new AtUri(payload.subject)
if (urip.collection === 'app.bsky.feed.post') {
return `/profile/${urip.host}/post/${urip.rkey}`
} else {
return '/notifications'
}
}
case 'reply':
case 'quote':
case 'mention':
case 'subscribed-post': {
const urip = new AtUri(payload.uri)
if (urip.collection === 'app.bsky.feed.post') {
return `/profile/${urip.host}/post/${urip.rkey}`
} else {
return '/notifications'
}
}
case 'follow':
case 'starterpack-joined': {
const urip = new AtUri(payload.uri)
return `/profile/${urip.host}`
}
case 'chat-message':
case 'chat-reaction':
case 'chat-added-to-group':
case 'chat-removed-from-group':
case 'chat-join-request-rejected':
return null
case 'verified':
case 'unverified':
return '/notifications'
default:
return null
}
}
+23
View File
@@ -0,0 +1,23 @@
// Thin wrapper around expo-notifications APIs called outside the dedicated
// notifications hooks. The .web.ts variant stubs these out so we don't pull
// expo-notifications into the web bundle.
import * as Notifications from 'expo-notifications'
export function getLastNotificationResponse() {
return Notifications.getLastNotificationResponse()
}
export function clearLastNotificationResponse() {
return Notifications.clearLastNotificationResponse()
}
export function getPermissionsAsync() {
return Notifications.getPermissionsAsync()
}
export function requestPermissionsAsync() {
return Notifications.requestPermissionsAsync()
}
export type NotificationPermissionsStatus =
Notifications.NotificationPermissionsStatus
+31
View File
@@ -0,0 +1,31 @@
// Web stubs. None of these are called on web (callers gate via IS_NATIVE /
// IS_WEB), but the imports must resolve to keep expo-notifications out of the
// web bundle.
export function getLastNotificationResponse(): null {
return null
}
export function clearLastNotificationResponse(): void {}
export type NotificationPermissionsStatus = {
status: 'granted' | 'denied' | 'undetermined'
granted: boolean
canAskAgain: boolean
expires: 'never' | number
}
const denied: NotificationPermissionsStatus = {
status: 'denied',
granted: false,
canAskAgain: false,
expires: 'never',
}
export async function getPermissionsAsync(): Promise<NotificationPermissionsStatus> {
return denied
}
export async function requestPermissionsAsync(): Promise<NotificationPermissionsStatus> {
return denied
}
@@ -0,0 +1,28 @@
// Web shim for #/lib/notifications/notifications. expo-notifications doesn't
// run on web, so all of these are no-ops; the corresponding native callers
// are gated by IS_NATIVE / Platform.OS, so on web they're never invoked.
//
// Keeping this file expo-notifications-free saves ~70KB on web.
import {type AtpAgent} from '@atproto/api'
export function useRegisterPushToken() {
return () => {}
}
export function useGetAndRegisterPushToken() {
return async () => undefined
}
export function useNotificationsRegistration() {}
export function useRequestNotificationsPermission() {
return async (
_context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',
) => {}
}
export async function decrementBadgeCount(_by: number) {}
export async function resetBadgeCount() {}
export async function unregisterPushToken(_agents: AtpAgent[]) {}
@@ -1,6 +1,5 @@
import {useEffect} from 'react'
import {Linking, View} from 'react-native'
import * as Notification from 'expo-notifications'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -8,6 +7,10 @@ import {Trans} from '@lingui/react/macro'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {useAppState} from '#/lib/appState'
import {
getPermissionsAsync,
requestPermissionsAsync,
} from '#/lib/notifications/expo-helpers'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
@@ -47,7 +50,7 @@ export function NotificationSettingsScreen({}: Props) {
queryKey: RQKEY,
queryFn: async () => {
if (IS_WEB) return null
return await Notification.getPermissionsAsync()
return await getPermissionsAsync()
},
})
@@ -61,7 +64,7 @@ export function NotificationSettingsScreen({}: Props) {
const onRequestPermissions = async () => {
if (IS_WEB) return
if (permissions?.canAskAgain) {
const response = await Notification.requestPermissionsAsync()
const response = await requestPermissionsAsync()
queryClient.setQueryData(RQKEY, response)
} else {
if (IS_ANDROID) {