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
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 notification 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,43 @@
"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": "~4.2.1",
"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",
"react-native-worklets": "~0.7.2"
},
"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 +55,12 @@
"private": true,
"engines": {
"node": "22.17.0"
},
"expo": {
"install": {
"exclude": [
"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
97 changes: 97 additions & 0 deletions packages/expo/src/modules/push/getNotificationsModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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
);
}

// Abstracted for testability — Vitest cannot intercept require() calls
// inside dynamically imported modules after vi.resetModules().
/* v8 ignore next 3 -- default require is replaced in tests via _resetForTesting */
let requireNotifications: () => NotificationsModule = () =>
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("expo-notifications") as NotificationsModule;

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 {
cachedModule = requireNotifications();
} catch {
console.warn(
"[Knock] expo-notifications could not be loaded. " +
"Push notification features will be disabled.",
);
cachedModule = null;
}

return cachedModule;
}

/**
* @internal Test-only — reset the cached module and optionally override
* the require function used to load expo-notifications.
*/
export function _resetForTesting(
overrideRequire?: () => NotificationsModule,
): void {
cachedModule = undefined;
if (overrideRequire) {
requireNotifications = overrideRequire;
}
}
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