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
5 changes: 5 additions & 0 deletions .changeset/expo-push-reregister-on-user-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/expo": patch
---

Fix Expo push auto-registration not re-running when the signed-in user changes in place. The auto-register effect now depends on the authenticated `userId`, so switching users on the same `KnockProvider` (where `isAuthenticated` never flips) re-registers the device token against the new user's channel data.
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const InternalExpoPushNotificationProvider: React.FC<
autoRegister = true,
}) => {
const knockClient = useKnockClient();
const { status: authStatus } = useKnockAuthState(knockClient);
const { status: authStatus, userId } = useKnockAuthState(knockClient);
const isAuthenticated = authStatus === "authenticated";
const { registerPushTokenToChannel, unregisterPushTokenFromChannel } =
usePushNotifications();
Expand Down Expand Up @@ -182,6 +182,9 @@ const InternalExpoPushNotificationProvider: React.FC<
// Gating on auth defers the OS permission prompt (prompting a logged-out user
// is hostile) and avoids registering a token against no user. Because
// `isAuthenticated` is a dependency, enabling auth later re-runs registration.
// `userId` is also a dependency so that an in-place user switch (same Knock
// instance re-authenticated as a different user, where `isAuthenticated` never
// flips) re-registers the device token against the new user's channel data.
useEffect(() => {
if (!autoRegister || !isAuthenticated) {
return;
Expand Down Expand Up @@ -210,6 +213,7 @@ const InternalExpoPushNotificationProvider: React.FC<
}, [
autoRegister,
isAuthenticated,
userId,
knockExpoChannelId,
registerForPushNotifications,
registerPushTokenToChannel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,20 @@ const mockKnockClient = {
},
};

// Controllable signed-in user id so a test can simulate an in-place user switch
// (same Knock instance re-authenticated as a different user).
let mockUserId: string | undefined = "user_1";

vi.mock("@knocklabs/react-core", () => ({
useKnockClient: () => mockKnockClient,
// Derive auth state from the same `isAuthenticated` mock so tests only have to
// toggle it in one place.
// toggle it in one place; `mockUserId` lets a test change the signed-in user
// while staying authenticated.
useKnockAuthState: () => ({
status: mockKnockClient.isAuthenticated()
? "authenticated"
: "unauthenticated",
userId: mockKnockClient.isAuthenticated() ? "user_1" : undefined,
userId: mockKnockClient.isAuthenticated() ? mockUserId : undefined,
userToken: undefined,
}),
}));
Expand Down Expand Up @@ -669,4 +674,50 @@ describe("KnockExpoPushNotificationProvider", () => {
// Should still only be called once
expect(getTokenSpy).toHaveBeenCalledTimes(1);
});

test("re-registers the push token when the authenticated user changes in place", async () => {
const NotificationsMock = await import("expo-notifications");
const getTokenSpy = vi.mocked(NotificationsMock.getExpoPushTokenAsync);
getTokenSpy.mockClear();
getTokenSpy.mockResolvedValue({ data: "test-token" });
mockRegisterPushTokenToChannel.mockClear();

const TestChild = () => <div data-testid="test-child">Test Child</div>;

try {
const { rerender } = render(
<KnockExpoPushNotificationProvider
knockExpoChannelId="test-channel-id"
autoRegister={true}
>
<TestChild />
</KnockExpoPushNotificationProvider>,
);

// Registers once for the initial user.
await waitFor(() => {
expect(mockRegisterPushTokenToChannel).toHaveBeenCalledTimes(1);
});

// Switch to a different user in place: same Knock instance, still
// authenticated, only the signed-in userId changes. `isAuthenticated`
// never flips, so the userId dependency is what drives re-registration.
mockUserId = "user_2";
rerender(
<KnockExpoPushNotificationProvider
knockExpoChannelId="test-channel-id"
autoRegister={true}
>
<TestChild />
</KnockExpoPushNotificationProvider>,
);

// The device token must be registered again for the new user.
await waitFor(() => {
expect(mockRegisterPushTokenToChannel).toHaveBeenCalledTimes(2);
});
} finally {
mockUserId = "user_1";
}
});
});
Loading