) => {
msTeams: {
authCheck: vi.fn(authCheckImpl),
},
+ // Minimal subscribable auth store so `useKnockAuthState` can read the userId.
+ authStore: {
+ state: {
+ status: "authenticated",
+ userId: "user_1",
+ userToken: undefined,
+ },
+ subscribe: () => () => {},
+ },
} as unknown as KnockClient;
};
@@ -89,4 +98,38 @@ describe("useMsTeamsConnectionStatus", () => {
await waitFor(() => expect(result.current.connectionStatus).toBe("error"));
});
+
+ it("re-checks the connection when the authenticated user changes", async () => {
+ const authCheck = vi.fn(() =>
+ Promise.resolve({ connection: { ok: true } }),
+ );
+ const buildKnockWithUser = (userId: string) =>
+ ({
+ msTeams: { authCheck },
+ authStore: {
+ state: { status: "authenticated", userId, userToken: undefined },
+ subscribe: () => () => {},
+ },
+ }) as unknown as KnockClient;
+
+ const { result, rerender } = renderHook(
+ ({ knock }) => useMsTeamsConnectionStatus(knock, channelId, tenantId),
+ { initialProps: { knock: buildKnockWithUser("user_A") } },
+ );
+
+ await waitFor(() =>
+ expect(result.current.connectionStatus).toBe("connected"),
+ );
+ expect(authCheck).toHaveBeenCalledTimes(1);
+
+ // Switching users must reset the latched status and re-run authCheck.
+ rerender({ knock: buildKnockWithUser("user_B") });
+
+ // Wait for the re-check to resolve back to "connected" (which only happens
+ // after the second authCheck resolves), then assert it ran again.
+ await waitFor(() =>
+ expect(result.current.connectionStatus).toBe("connected"),
+ );
+ expect(authCheck).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/packages/react-core/test/slack/KnockSlackProvider.test.tsx b/packages/react-core/test/slack/KnockSlackProvider.test.tsx
new file mode 100644
index 000000000..9f7f629be
--- /dev/null
+++ b/packages/react-core/test/slack/KnockSlackProvider.test.tsx
@@ -0,0 +1,48 @@
+import { render } from "@testing-library/react";
+import { describe, expect, test, vi } from "vitest";
+
+import { KnockProvider, KnockSlackProvider, useKnockSlackClient } from "../../src";
+
+// Mock the connection-status hook to isolate the provider (and avoid a real
+// authCheck request). The provider imports it via the slack barrel, so mock the
+// leaf module it ultimately resolves to.
+vi.mock("../../src/modules/slack/hooks/useSlackConnectionStatus", () => ({
+ default: () => ({
+ connectionStatus: "connected",
+ setConnectionStatus: vi.fn(),
+ errorLabel: null,
+ setErrorLabel: vi.fn(),
+ actionLabel: null,
+ setActionLabel: vi.fn(),
+ }),
+}));
+
+describe("KnockSlackProvider", () => {
+ test("renders consumer with correct context", () => {
+ const TestConsumer = () => {
+ const { knockSlackChannelId, tenantId, connectionStatus } =
+ useKnockSlackClient();
+
+ return (
+
+ {knockSlackChannelId}-{tenantId}-{connectionStatus}
+
+ );
+ };
+
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId("consumer-msg")).toHaveTextContent(
+ "channel_123-tenant_987-connected",
+ );
+ });
+});
diff --git a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx
index 18bbe958e..945af4573 100644
--- a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx
+++ b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx
@@ -1,5 +1,5 @@
import type KnockClient from "@knocklabs/client";
-import { renderHook, waitFor } from "@testing-library/react";
+import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import useSlackConnectionStatus from "../../src/modules/slack/hooks/useSlackConnectionStatus";
@@ -10,6 +10,15 @@ const buildMockKnock = (authCheckImpl: () => Promise) => {
slack: {
authCheck: vi.fn(authCheckImpl),
},
+ // Minimal subscribable auth store so `useKnockAuthState` can read the userId.
+ authStore: {
+ state: {
+ status: "authenticated",
+ userId: "user_1",
+ userToken: undefined,
+ },
+ subscribe: () => () => {},
+ },
} as unknown as KnockClient;
};
@@ -89,4 +98,162 @@ describe("useSlackConnectionStatus", () => {
expect(result.current.errorLabel).toBe("Account inactive"),
);
});
+
+ it("re-checks the connection when the authenticated user changes", async () => {
+ const authCheck = vi.fn(() =>
+ Promise.resolve({ connection: { ok: true } }),
+ );
+ const buildKnockWithUser = (userId: string) =>
+ ({
+ slack: { authCheck },
+ authStore: {
+ state: { status: "authenticated", userId, userToken: undefined },
+ subscribe: () => () => {},
+ },
+ }) as unknown as KnockClient;
+
+ const { result, rerender } = renderHook(
+ ({ knock }) => useSlackConnectionStatus(knock, channelId, tenantId),
+ { initialProps: { knock: buildKnockWithUser("user_A") } },
+ );
+
+ await waitFor(() =>
+ expect(result.current.connectionStatus).toBe("connected"),
+ );
+ expect(authCheck).toHaveBeenCalledTimes(1);
+
+ // Switching users must reset the latched status and re-run authCheck.
+ rerender({ knock: buildKnockWithUser("user_B") });
+
+ // Wait for the re-check to resolve back to "connected" (which only happens
+ // after the second authCheck resolves), then assert it ran again.
+ await waitFor(() =>
+ expect(result.current.connectionStatus).toBe("connected"),
+ );
+ expect(authCheck).toHaveBeenCalledTimes(2);
+ });
+
+ it("ignores a superseded authCheck when the user switches mid-flight", async () => {
+ // Same client/slack instance across the switch; only the auth store's userId
+ // changes (an in-place re-auth), and the switch happens while the first
+ // authCheck is still in flight (status is still "connecting").
+ const makeAuthStore = (userId: string) => {
+ let state = { status: "authenticated", userId, userToken: undefined };
+ const listeners = new Set<() => void>();
+ return {
+ get state() {
+ return state;
+ },
+ subscribe(cb: () => void) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ setUserId(next: string) {
+ state = { ...state, userId: next };
+ listeners.forEach((cb) => cb());
+ },
+ };
+ };
+
+ const resolvers: Array<(v: unknown) => void> = [];
+ const authCheck = vi.fn(
+ () => new Promise((resolve) => resolvers.push(resolve)),
+ );
+ const authStore = makeAuthStore("user_A");
+ const knock = { slack: { authCheck }, authStore } as unknown as KnockClient;
+
+ const { result } = renderHook(() =>
+ useSlackConnectionStatus(knock, channelId, tenantId),
+ );
+
+ // First check is in flight for user_A; status is still "connecting".
+ await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(1));
+ expect(result.current.connectionStatus).toBe("connecting");
+
+ // Switch user while the first check is in flight (status stays "connecting",
+ // so only the userId dep can drive the re-check).
+ act(() => authStore.setUserId("user_B"));
+ await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(2));
+
+ // user_B resolves first -> disconnected.
+ await act(async () => {
+ resolvers[1]!({ connection: { ok: false } });
+ await Promise.resolve();
+ });
+ await waitFor(() =>
+ expect(result.current.connectionStatus).toBe("disconnected"),
+ );
+
+ // The superseded user_A check resolves late; its result must be ignored, so
+ // the status stays "disconnected" rather than latching to "connected".
+ await act(async () => {
+ resolvers[0]!({ connection: { ok: true } });
+ await Promise.resolve();
+ });
+ expect(result.current.connectionStatus).toBe("disconnected");
+ });
+
+ it("ignores a superseded authCheck that rejects after the user switches mid-flight", async () => {
+ // Same setup as the test above, but the stale (user_A) check *rejects*
+ // instead of resolving. The late rejection must be swallowed so the status
+ // stays with user_B's result rather than flipping to "error".
+ const makeAuthStore = (userId: string) => {
+ let state = { status: "authenticated", userId, userToken: undefined };
+ const listeners = new Set<() => void>();
+ return {
+ get state() {
+ return state;
+ },
+ subscribe(cb: () => void) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ setUserId(next: string) {
+ state = { ...state, userId: next };
+ listeners.forEach((cb) => cb());
+ },
+ };
+ };
+
+ const resolvers: Array<(v: unknown) => void> = [];
+ const rejecters: Array<(e: unknown) => void> = [];
+ const authCheck = vi.fn(
+ () =>
+ new Promise((resolve, reject) => {
+ resolvers.push(resolve);
+ rejecters.push(reject);
+ }),
+ );
+ const authStore = makeAuthStore("user_A");
+ const knock = { slack: { authCheck }, authStore } as unknown as KnockClient;
+
+ const { result } = renderHook(() =>
+ useSlackConnectionStatus(knock, channelId, tenantId),
+ );
+
+ // First check is in flight for user_A; status is still "connecting".
+ await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(1));
+ expect(result.current.connectionStatus).toBe("connecting");
+
+ // Switch user while the first check is in flight, superseding user_A.
+ act(() => authStore.setUserId("user_B"));
+ await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(2));
+
+ // user_B resolves first -> disconnected.
+ await act(async () => {
+ resolvers[1]!({ connection: { ok: false } });
+ await Promise.resolve();
+ });
+ await waitFor(() =>
+ expect(result.current.connectionStatus).toBe("disconnected"),
+ );
+
+ // The superseded user_A check rejects late; the error must be ignored, so
+ // the status stays "disconnected" rather than latching to "error".
+ await act(async () => {
+ rejecters[0]!(new Error("network blip"));
+ await Promise.resolve();
+ });
+ expect(result.current.connectionStatus).toBe("disconnected");
+ });
});
diff --git a/packages/react-native/CHANGELOG.md b/packages/react-native/CHANGELOG.md
index 3af2ad85d..34a96d5ef 100644
--- a/packages/react-native/CHANGELOG.md
+++ b/packages/react-native/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## 0.10.0-rc.0
+
+### Minor Changes
+
+- d2f7948: Add an `enabled` prop to `KnockProvider` (and an `enabled` option to `useAuthenticatedKnockClient`).
+
+ When `enabled` is `false`, the provider still renders its children but the Knock client sits idle: no identify call, no API requests, no websocket. Set it to `true` and it connects like a login; set it back to `false` and it disconnects and clears its data like a logout. It defaults to `true`, so existing code is unaffected.
+
+ Use this instead of conditionally mounting `KnockProvider`, for example to wait for a user token that loads asynchronously:
+
+ ```tsx
+
+ ```
+
+ Also fixed:
+
+ - `useFeedSettings` no longer calls `GET /v1/users/undefined/feeds/.../settings` when there's no user.
+ - `KnockProvider` now disconnects its client (websocket, token-refresh timer, listener) when it unmounts, instead of leaving them running.
+
+- d2f7948: Add `useKnockAuthState()` and make Slack, MS Teams, and Expo respond to sign-in changes.
+
+ - New `useKnockAuthState(knock)` hook re-renders when the user signs in, signs out, or switches.
+ - Slack and MS Teams connection status now re-checks when the user changes, instead of checking once and sticking with that result.
+ - Expo waits for a signed-in user before registering for push notifications, so logged-out users don't see the OS permission prompt. A notification tapped while logged out no longer tries to update its status.
+
+### Patch Changes
+
+- Updated dependencies [d2f7948]
+- Updated dependencies [d2f7948]
+- Updated dependencies [d2f7948]
+ - @knocklabs/client@0.22.0-rc.0
+ - @knocklabs/react-core@0.14.0-rc.0
+
## 0.9.9
### Patch Changes
diff --git a/packages/react-native/README.md b/packages/react-native/README.md
index 41088ecb0..42fd27558 100644
--- a/packages/react-native/README.md
+++ b/packages/react-native/README.md
@@ -55,6 +55,23 @@ const YourAppLayout = () => {
};
```
+## Deferring activity with `enabled`
+
+`KnockProvider` takes an `enabled` prop (default `true`). When it's `false`, the provider still renders its children but the Knock client sits idle: it doesn't identify the user, make any API requests, or open a websocket. Set it back to `true` and it connects like a login; set it to `false` again and it disconnects and clears its data like a logout.
+
+This is the recommended way to gate the provider on a complete identity — for example an enhanced-security user token that loads asynchronously — instead of conditionally mounting `KnockProvider`:
+
+```jsx
+
+ {/* ... */}
+
+```
+
## Headless usage
Alternatively, if you don't want to use our components you can render the feed in a headless mode using our hooks:
diff --git a/packages/react-native/package.json b/packages/react-native/package.json
index b1242d55c..0ef075924 100644
--- a/packages/react-native/package.json
+++ b/packages/react-native/package.json
@@ -1,6 +1,6 @@
{
"name": "@knocklabs/react-native",
- "version": "0.9.9",
+ "version": "0.10.0-rc.0",
"author": "@knocklabs",
"license": "MIT",
"main": "dist/cjs/index.js",
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index a10e0f201..198dcb4e8 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## 0.12.0-rc.0
+
+### Minor Changes
+
+- d2f7948: Add an `enabled` prop to `KnockProvider` (and an `enabled` option to `useAuthenticatedKnockClient`).
+
+ When `enabled` is `false`, the provider still renders its children but the Knock client sits idle: no identify call, no API requests, no websocket. Set it to `true` and it connects like a login; set it back to `false` and it disconnects and clears its data like a logout. It defaults to `true`, so existing code is unaffected.
+
+ Use this instead of conditionally mounting `KnockProvider`, for example to wait for a user token that loads asynchronously:
+
+ ```tsx
+
+ ```
+
+ Also fixed:
+
+ - `useFeedSettings` no longer calls `GET /v1/users/undefined/feeds/.../settings` when there's no user.
+ - `KnockProvider` now disconnects its client (websocket, token-refresh timer, listener) when it unmounts, instead of leaving them running.
+
+- d2f7948: Add `useKnockAuthState()` and make Slack, MS Teams, and Expo respond to sign-in changes.
+
+ - New `useKnockAuthState(knock)` hook re-renders when the user signs in, signs out, or switches.
+ - Slack and MS Teams connection status now re-checks when the user changes, instead of checking once and sticking with that result.
+ - Expo waits for a signed-in user before registering for push notifications, so logged-out users don't see the OS permission prompt. A notification tapped while logged out no longer tries to update its status.
+
+### Patch Changes
+
+- Updated dependencies [d2f7948]
+- Updated dependencies [d2f7948]
+- Updated dependencies [d2f7948]
+ - @knocklabs/client@0.22.0-rc.0
+ - @knocklabs/react-core@0.14.0-rc.0
+
## 0.11.24
### Patch Changes
diff --git a/packages/react/README.md b/packages/react/README.md
index 388c916d9..3da6e21a4 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -73,6 +73,23 @@ const YourAppLayout = () => {
};
```
+## Deferring activity with `enabled`
+
+`KnockProvider` takes an `enabled` prop (default `true`). When it's `false`, the provider still renders its children but the Knock client sits idle: it doesn't identify the user, make any API requests, or open a websocket. Set it back to `true` and it connects like a login; set it to `false` again and it disconnects and clears its data like a logout.
+
+This is the recommended way to gate the provider on a complete identity — for example an enhanced-security user token that loads asynchronously — instead of conditionally mounting `KnockProvider`:
+
+```jsx
+
+ {/* ... */}
+
+```
+
## Headless usage
Alternatively, if you don't want to use our components you can render the feed in a headless mode using our hooks:
diff --git a/packages/react/package.json b/packages/react/package.json
index 946390ad9..a69ec9b0b 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -2,7 +2,7 @@
"name": "@knocklabs/react",
"description": "A set of React components to build notification experiences powered by Knock",
"author": "@knocklabs",
- "version": "0.11.24",
+ "version": "0.12.0-rc.0",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.mjs",
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index cdec0177e..325170aae 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -79,6 +79,7 @@ export {
type KnockProviderProps,
type KnockProviderState,
useAuthenticatedKnockClient,
+ useKnockAuthState,
useKnockClient,
useStableOptions,
KnockFeedProvider,
diff --git a/packages/react/test/barrels/publicApi.test.ts b/packages/react/test/barrels/publicApi.test.ts
index c05d7851d..1517e962b 100644
--- a/packages/react/test/barrels/publicApi.test.ts
+++ b/packages/react/test/barrels/publicApi.test.ts
@@ -9,6 +9,10 @@ const expectedExports = [
"NotificationFeed",
"NotificationIconButton",
"UnseenBadge",
+ // Hooks forwarded from @knocklabs/react-core (this package curates its exports
+ // by name, so a missing forward is otherwise invisible to type-check).
+ "useKnockClient",
+ "useKnockAuthState",
];
describe("Public API barrel exports", () => {
diff --git a/yarn.lock b/yarn.lock
index 60320e3f7..97c300c44 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5102,7 +5102,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@knocklabs/react@npm:0.11.24, @knocklabs/react@workspace:*, @knocklabs/react@workspace:^, @knocklabs/react@workspace:packages/react":
+"@knocklabs/react@npm:0.12.0-rc.0, @knocklabs/react@workspace:*, @knocklabs/react@workspace:^, @knocklabs/react@workspace:packages/react":
version: 0.0.0-use.local
resolution: "@knocklabs/react@workspace:packages/react"
dependencies:
@@ -17080,7 +17080,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "ms-teams-connect-example@workspace:examples/ms-teams-connect-example"
dependencies:
- "@knocklabs/react": "npm:0.11.24"
+ "@knocklabs/react": "npm:0.12.0-rc.0"
"@types/jsonwebtoken": "npm:^9.0.10"
"@types/node": "npm:^24"
"@types/react": "npm:^19.2.17"
@@ -19974,7 +19974,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "slack-connect-example@workspace:examples/slack-connect-example"
dependencies:
- "@knocklabs/react": "npm:0.11.24"
+ "@knocklabs/react": "npm:0.12.0-rc.0"
"@types/jsonwebtoken": "npm:^9.0.10"
"@types/node": "npm:^24"
"@types/react": "npm:^19.2.17"
@@ -19994,7 +19994,7 @@ __metadata:
resolution: "slack-kit-example@workspace:examples/slack-kit-example"
dependencies:
"@knocklabs/node": "npm:^1.30.0"
- "@knocklabs/react": "npm:0.11.24"
+ "@knocklabs/react": "npm:0.12.0-rc.0"
"@tailwindcss/postcss": "npm:^4.3.0"
"@types/node": "npm:^24"
"@types/react": "npm:^19.2.17"