Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions .changeset/old-cats-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@knocklabs/expo-example": minor
"@knocklabs/react-native": minor
"@knocklabs/expo": minor
---

chore: upgrade expo and react native packages to their latest version, ensure expo go builds on android get the proper warning about deprecated notifcation support from expo, ensure expo-example app works as expected.
5 changes: 1 addition & 4 deletions examples/expo-example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"icon": "./assets/images/icon.png",
"scheme": "expoexample",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.knocklabs.expoexample"
Expand All @@ -17,7 +16,6 @@
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.knocklabs.expoexample"
},
"web": {
Expand All @@ -38,8 +36,7 @@
]
],
"experiments": {
"typedRoutes": true,
"reactCanary": true
"typedRoutes": true
},
"extra": {
"router": {},
Expand Down
61 changes: 35 additions & 26 deletions examples/expo-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,41 +10,42 @@
"lint": "expo lint"
},
"dependencies": {
"@expo/log-box": "~55.0.8",
"@expo/vector-icons": "^15.0.2",
"@knocklabs/expo": "workspace:^",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.6",
"expo": "~53.0.22",
"expo-blur": "~14.1.5",
"expo-constants": "~17.1.7",
"expo-device": "^7.1.4",
"expo-font": "~13.3.2",
"expo-haptics": "~14.1.4",
"expo-image": "~2.4.0",
"expo-linking": "~7.1.7",
"expo-notifications": "^0.31.4",
"expo-router": "~5.1.6",
"expo-splash-screen": "~0.30.10",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.11",
"expo-web-browser": "~14.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.14",
"@react-navigation/native": "^7.1.33",
"expo": "~55.0.9",
"expo-blur": "~55.0.10",
"expo-constants": "~55.0.9",
"expo-device": "~55.0.10",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.9",
"expo-image": "~55.0.6",
"expo-linking": "~55.0.9",
"expo-notifications": "~55.0.14",
"expo-router": "~55.0.8",
"expo-splash-screen": "~55.0.13",
"expo-status-bar": "~55.0.4",
"expo-symbols": "~55.0.5",
"expo-system-ui": "~55.0.11",
"expo-web-browser": "~55.0.10",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-native": "^0.79.2",
"react-native-gesture-handler": "^2.27.1",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-web": "~0.21.2",
"react-native-webview": "13.13.5"
"react-native": "^0.83.4",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "~3.19.5",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.0"
},
"devDependencies": {
"@babel/core": "^7.28.0",
"@types/react": "^19.1.8",
"eslint": "^8.56.0",
"eslint-config-expo": "~9.2.0",
"eslint-config-expo": "~55.0.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.1",
Expand All @@ -53,5 +54,13 @@
"private": true,
"engines": {
"node": "22.17.0"
},
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"typescript"
]
}
}
}
14 changes: 7 additions & 7 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@
"@knocklabs/client": "workspace:^",
"@knocklabs/react-core": "workspace:^",
"@knocklabs/react-native": "workspace:^",
"react-native-gesture-handler": "^2.27.1",
"react-native-gesture-handler": "~2.30.0",
"react-native-render-html": "^6.3.4",
"react-native-svg": "^15.12.0"
"react-native-svg": "15.15.3"
},
"devDependencies": {
"@codecov/vite-plugin": "^1.9.1",
Expand All @@ -70,13 +70,13 @@
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.2",
"expo": "~53.0.22",
"expo-constants": "~17.1.7",
"expo-device": "^7.1.4",
"expo-notifications": "^0.31.4",
"expo": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-device": "~55.0.10",
"expo-notifications": "~55.0.14",
"jsdom": "^27.1.0",
"react": "^19.0.0",
"react-native": "^0.79.2",
"react-native": "^0.83.4",
"rimraf": "^6.0.1",
"typescript": "^5.8.3",
"vite": "^5.4.19",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
KnockPushNotificationProvider,
usePushNotifications,
} from "@knocklabs/react-native";
import * as Notifications from "expo-notifications";
import React, {
createContext,
useCallback,
Expand All @@ -14,6 +13,11 @@ import React, {
useState,
} from "react";

import {
type Notification,
type NotificationResponse,
getNotificationsModule,
} from "./getNotificationsModule";
import type {
KnockExpoPushNotificationContextType,
KnockExpoPushNotificationProviderProps,
Expand Down Expand Up @@ -70,18 +74,18 @@ const InternalExpoPushNotificationProvider: React.FC<

// Use refs for handlers to avoid re-running effects when handlers change
const notificationReceivedHandlerRef = useRef<
(notification: Notifications.Notification) => void
(notification: Notification) => void
>(() => {});

const notificationTappedHandlerRef = useRef<
(response: Notifications.NotificationResponse) => void
(response: NotificationResponse) => void
>(() => {});

/**
* Register a handler to be called when a notification is received in the foreground.
*/
const onNotificationReceived = useCallback(
(handler: (notification: Notifications.Notification) => void) => {
(handler: (notification: Notification) => void) => {
notificationReceivedHandlerRef.current = handler;
},
[],
Expand All @@ -91,7 +95,7 @@ const InternalExpoPushNotificationProvider: React.FC<
* Register a handler to be called when a notification is tapped.
*/
const onNotificationTapped = useCallback(
(handler: (response: Notifications.NotificationResponse) => void) => {
(handler: (response: NotificationResponse) => void) => {
notificationTappedHandlerRef.current = handler;
},
[],
Expand Down Expand Up @@ -130,7 +134,7 @@ const InternalExpoPushNotificationProvider: React.FC<
*/
const updateMessageStatus = useCallback(
async (
notification: Notifications.Notification,
notification: Notification,
status: MessageEngagementStatus,
): Promise<Message | void> => {
const messageId = notification.request.content.data?.[
Expand All @@ -153,11 +157,14 @@ const InternalExpoPushNotificationProvider: React.FC<

// Set up the notification handler for foreground notifications
useEffect(() => {
const NotificationsModule = getNotificationsModule();
if (!NotificationsModule) return;

const handleNotification = customNotificationHandler
? customNotificationHandler
: async () => DEFAULT_NOTIFICATION_BEHAVIOR;

Notifications.setNotificationHandler({ handleNotification });
NotificationsModule.setNotificationHandler({ handleNotification });
}, [customNotificationHandler]);

// Auto-register for push notifications on mount if enabled
Expand Down Expand Up @@ -196,20 +203,24 @@ const InternalExpoPushNotificationProvider: React.FC<

// Set up notification listeners for received and tapped notifications
useEffect(() => {
const receivedSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
const NotificationsModule = getNotificationsModule();
if (!NotificationsModule) return;

const receivedSubscription =
NotificationsModule.addNotificationReceivedListener((notification) => {
knockClient.log("[Knock] Notification received in foreground");
updateMessageStatus(notification, "interacted");
notificationReceivedHandlerRef.current(notification);
},
);
});

const responseSubscription =
Notifications.addNotificationResponseReceivedListener((response) => {
knockClient.log("[Knock] Notification was tapped");
updateMessageStatus(response.notification, "interacted");
notificationTappedHandlerRef.current(response);
});
NotificationsModule.addNotificationResponseReceivedListener(
(response) => {
knockClient.log("[Knock] Notification was tapped");
updateMessageStatus(response.notification, "interacted");
notificationTappedHandlerRef.current(response);
},
);

return () => {
receivedSubscription.remove();
Expand Down
78 changes: 78 additions & 0 deletions packages/expo/src/modules/push/getNotificationsModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Constants, { ExecutionEnvironment } from "expo-constants";
import type * as Notifications from "expo-notifications";
import { Platform } from "react-native";

/**
* The type of the expo-notifications module when successfully loaded.
*/
export type NotificationsModule = typeof Notifications;

// Type aliases derived from the expo-notifications namespace so that consumers
// access all expo-notifications types through this module rather than importing
// from the package directly (which can trigger runtime side-effects).
export type Notification = Notifications.Notification;
export type NotificationResponse = Notifications.NotificationResponse;
export type NotificationBehavior = Notifications.NotificationBehavior;

/**
* Lazily load the expo-notifications module.
*
* In Expo SDK 55+, `import * as Notifications from "expo-notifications"` triggers
* a top-level side-effect (DevicePushTokenAutoRegistration.fx.ts) that calls
* `addPushTokenListener()`, which throws on Android Expo Go where push notification
* functionality has been removed (since SDK 53).
*
* We detect Android Expo Go before attempting the require() and skip it entirely,
* since the throw from expo-notifications bypasses JavaScript try/catch via
* React Native's global error handler.
*
* On all other environments (iOS Expo Go, development builds, production),
* expo-notifications loads normally.
*/

// Cache the module after the first load to avoid repeated require() calls and
// environment detection checks on every access. The three states are:
// undefined = not yet loaded (initial)
// null = unavailable (Android Expo Go or load failure)
// module = successfully loaded
let cachedModule: NotificationsModule | null | undefined = undefined;

function isAndroidExpoGo(): boolean {
return (
Platform.OS === "android" &&
Constants.executionEnvironment === ExecutionEnvironment.StoreClient
);
}

export function getNotificationsModule(): NotificationsModule | null {
if (cachedModule !== undefined) {
return cachedModule;
}

if (isAndroidExpoGo()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this have a version check as well? E.g. checking the package version that they're running or the android device version?

console.warn(
"[Knock] Push notifications (remote notifications) are not available in Expo Go " +
"on Android. This is an Expo platform limitation — push notification support was " +
"removed from Expo Go on Android in SDK 53. Push features (token registration, " +
"notification listeners) will be disabled, but all other Knock features will " +
"continue to work.\n\n" +
"To use push notifications on Android, use a development build instead of Expo Go: " +
"https://docs.expo.dev/develop/development-builds/introduction/",
);
cachedModule = null;
return cachedModule;
}

try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require("expo-notifications") as NotificationsModule;
} catch {
console.warn(
"[Knock] expo-notifications could not be loaded. " +
"Push notification features will be disabled.",
);
cachedModule = null;
}

return cachedModule;
}
15 changes: 10 additions & 5 deletions packages/expo/src/modules/push/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { KnockPushNotificationContextType } from "@knocklabs/react-native";
import type * as Notifications from "expo-notifications";
import type React from "react";

import type {
Notification,
NotificationBehavior,
NotificationResponse,
} from "./getNotificationsModule";

/**
* Context type for the Expo push notification provider.
* Extends the base push notification context with Expo-specific functionality.
Expand All @@ -16,12 +21,12 @@ export interface KnockExpoPushNotificationContextType

/** Register a handler for when a notification is received in the foreground */
onNotificationReceived: (
handler: (notification: Notifications.Notification) => void,
handler: (notification: Notification) => void,
) => void;

/** Register a handler for when a notification is tapped */
onNotificationTapped: (
handler: (response: Notifications.NotificationResponse) => void,
handler: (response: NotificationResponse) => void,
) => void;
}

Expand All @@ -37,8 +42,8 @@ export interface KnockExpoPushNotificationProviderProps {
* If not provided, notifications will show alerts, play sounds, and set badges.
*/
customNotificationHandler?: (
notification: Notifications.Notification,
) => Promise<Notifications.NotificationBehavior>;
notification: Notification,
) => Promise<NotificationBehavior>;

/**
* Custom function to set up the Android notification channel.
Expand Down
Loading
Loading