) => {
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/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/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/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", () => {