From 1203b329e8f22b0df0166c1134a36fa509abf4af Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:25:07 -0500 Subject: [PATCH 01/14] feat(client): SDK-wide quiescence, logout, and auth-state signal Add a subscribable authStore + authStatus getter and a Knock.logout() that tears down all stateful connections (socket, token-expiration timer, and page-visibility listener) and lazily re-creates the API client. Make user-scoped calls quiescent while unauthenticated instead of throwing or firing blind: - Feed markAs*/markAll*/fetchNextPage no-op without an optimistic store write. - Guide fetch/subscribe/step-marks no longer throw (fixes the crash when Guides render before a user is set). - Slack/MS Teams authCheck return a disconnected shape; getChannels/getTeams return empty; messages.batchUpdateStatuses returns []. Also fix two guide bugs the enabled prop exacerbates: re-read the socket from the API client on each subscribe so real-time survives re-auth, and share the history pushState/replaceState patch per window so remounting a guide provider no longer nests patches or leaves the originals unrestored. --- ...knockprovider-enabled-client-quiescence.md | 15 ++ packages/client/src/clients/feed/feed.ts | 40 ++++ packages/client/src/clients/feed/index.ts | 5 + packages/client/src/clients/guide/client.ts | 172 ++++++++++++---- packages/client/src/clients/messages/index.ts | 10 + packages/client/src/clients/ms-teams/index.ts | 24 +++ packages/client/src/clients/slack/index.ts | 17 ++ packages/client/src/interfaces.ts | 18 ++ packages/client/src/knock.ts | 94 ++++++++- .../client/test/clients/feed/feed.test.ts | 57 ++++++ .../client/test/clients/guide/guide.test.ts | 189 +++++++++++++++++- .../test/clients/messages/messages.test.ts | 14 ++ .../test/clients/ms-teams/ms-teams.test.ts | 42 ++++ .../client/test/clients/slack/slack.test.ts | 28 +++ packages/client/test/knock.test.ts | 158 +++++++++++++++ 15 files changed, 833 insertions(+), 50 deletions(-) create mode 100644 .changeset/knockprovider-enabled-client-quiescence.md diff --git a/.changeset/knockprovider-enabled-client-quiescence.md b/.changeset/knockprovider-enabled-client-quiescence.md new file mode 100644 index 000000000..05e831976 --- /dev/null +++ b/.changeset/knockprovider-enabled-client-quiescence.md @@ -0,0 +1,15 @@ +--- +"@knocklabs/client": minor +--- + +Add a subscribable authentication-state signal and make the client fully quiescent while unauthenticated. This is the `@knocklabs/client` foundation for an upcoming `enabled` prop on `KnockProvider`. + +- `Knock` now exposes a subscribable `authStore` (a `@tanstack/store`) and an `authStatus` getter (`"authenticated" | "unauthenticated"`), updated on every `authenticate()` and `logout()`. +- New `Knock.logout()` clears credentials and tears down all stateful connections (feed channels, socket, token-expiration timer, and page-visibility listener), then lazily recreates the API client on next use. Re-authenticating after a logout rewires any surviving feed instances. +- Unauthenticated calls are now quiet no-ops instead of firing requests or throwing: + - Feed `markAs*` / `markAll*` / `fetchNextPage` skip the network **and** the optimistic store update. + - Guide `fetch()` / `subscribe()` and step `markAsSeen` / `markAsInteracted` / `markAsArchived` no longer throw (`fetch()` resolves to an error status). This fixes a crash when Guides render before a user is authenticated. + - Slack and MS Teams `authCheck` return a disconnected result, and `getChannels` / `getTeams` return empty results. + - `messages.batchUpdateStatuses` returns an empty array. +- Fix: the guide client now re-reads its socket from the API client on each `subscribe()`, so guide real-time keeps working after a re-authentication (previously it captured the socket once at construction and went stale on user/token changes). +- Fix: the guide client's `history.pushState`/`replaceState` monkey-patch is now shared and idempotent per window, so remounting a guide provider (e.g. toggling `enabled`) no longer nests patches or leaves the originals unrestored. diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index d78f053ae..02dc3ca64 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -164,7 +164,25 @@ class Feed { return this.store.getState(); } + /** + * Returns `true` when the current user is authenticated. When not, logs and + * returns `false` so mutation methods can no-op early — without touching the + * store (no optimistic update) or the network. This upholds the SDK-wide + * "unauthenticated ⇒ quiescent" invariant for auto-fired paths such as the + * popover's `markAllAsSeen` on open or a cell's `markAsInteracted` on click. + */ + private canMutate(operation: string): boolean { + if (this.knock.isAuthenticated()) { + return true; + } + + this.knock.log(`[Feed] User is not authenticated, skipping ${operation}`); + return false; + } + async markAsSeen(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsSeen")) return; + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -177,6 +195,8 @@ class Feed { } async markAllAsSeen() { + if (!this.canMutate("markAllAsSeen")) return; + // To mark all of the messages as seen we: // 1. Optimistically update *everything* we have in the store // 2. We decrement the `unseen_count` to zero optimistically @@ -219,6 +239,8 @@ class Feed { } async markAsUnseen(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsUnseen")) return; + this.optimisticallyPerformStatusUpdate( itemOrItems, "unseen", @@ -230,6 +252,8 @@ class Feed { } async markAsRead(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsRead")) return; + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -242,6 +266,8 @@ class Feed { } async markAllAsRead() { + if (!this.canMutate("markAllAsRead")) return; + // To mark all of the messages as read we: // 1. Optimistically update *everything* we have in the store // 2. We decrement the `unread_count` to zero optimistically @@ -284,6 +310,8 @@ class Feed { } async markAsUnread(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsUnread")) return; + this.optimisticallyPerformStatusUpdate( itemOrItems, "unread", @@ -298,6 +326,8 @@ class Feed { itemOrItems: FeedItemOrItems, metadata?: Record, ) { + if (!this.canMutate("markAsInteracted")) return; + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -321,6 +351,8 @@ class Feed { TODO: how do we handle rollbacks? */ async markAsArchived(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsArchived")) return; + const state = this.store.getState(); const shouldOptimisticallyRemoveItems = @@ -392,6 +424,8 @@ class Feed { } async markAllAsArchived() { + if (!this.canMutate("markAllAsArchived")) return; + // Note: there is the potential for a race condition here because the bulk // update is an async method, so if a new message comes in during this window before // the update has been processed we'll effectively reset the `unseen_count` to be what it was. @@ -419,6 +453,8 @@ class Feed { } async markAllReadAsArchived() { + if (!this.canMutate("markAllReadAsArchived")) return; + // Note: there is the potential for a race condition here because the bulk // update is an async method, so if a new message comes in during this window before // the update has been processed we'll effectively reset the `unseen_count` to be what it was. @@ -461,6 +497,8 @@ class Feed { } async markAsUnarchived(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsUnarchived")) return; + const state = this.store.getState(); const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; @@ -595,6 +633,8 @@ class Feed { } async fetchNextPage(options: FetchFeedOptions = {}) { + if (!this.canMutate("fetchNextPage")) return; + // Attempts to fetch the next page of results (if we have any) const { pageInfo } = this.store.getState(); diff --git a/packages/client/src/clients/feed/index.ts b/packages/client/src/clients/feed/index.ts index d62505779..eb5356cc7 100644 --- a/packages/client/src/clients/feed/index.ts +++ b/packages/client/src/clients/feed/index.ts @@ -30,6 +30,11 @@ class FeedClient { this.feedInstances = this.feedInstances.filter((f) => f !== feed); } + /** Whether this client currently manages any feed instances. */ + hasInstances() { + return this.feedInstances.length > 0; + } + teardownInstances() { for (const feed of this.feedInstances) { feed.teardown(); diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index 37bc26a16..5abd97a02 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -70,6 +70,80 @@ const checkForWindow = () => { } }; +type LocationListener = () => void; + +// State for the shared history monkey-patch is stored on the `history` object +// itself so it is naturally scoped per-window and free of module-level globals. +type PatchedHistory = History & { + __knockGuidePatch?: { + listeners: Set; + originalPushState: History["pushState"]; + originalReplaceState: History["replaceState"]; + }; +}; + +// Register a location-change listener, installing a single shared patch of +// `history.pushState`/`replaceState` for programmatic navigation. This is +// idempotent across guide client instances: a provider remount (e.g. toggling +// `enabled`) constructs a new client while the old one is still tearing down, so +// patching per-instance would nest proxies and clobber the restore. Sharing one +// patch keyed on the `history` object avoids that entirely. +const addHistoryLocationListener = ( + history: PatchedHistory, + listener: LocationListener, +) => { + if (!history.__knockGuidePatch) { + const listeners = new Set(); + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + // Notify on the next tick so the browser state can settle first. + const notify = () => + setTimeout(() => { + for (const l of listeners) l(); + }, 0); + + const wrap = ( + target: T, + ): T => + new Proxy(target, { + apply: (fn, thisArg, args) => { + const result = Reflect.apply(fn, thisArg, args); + notify(); + return result; + }, + }) as T; + + history.pushState = wrap(originalPushState); + history.replaceState = wrap(originalReplaceState); + history.__knockGuidePatch = { + listeners, + originalPushState, + originalReplaceState, + }; + } + + history.__knockGuidePatch.listeners.add(listener); +}; + +// Remove a location-change listener, restoring the original history methods once +// the last guide client on this window has gone away. +const removeHistoryLocationListener = ( + history: PatchedHistory, + listener: LocationListener, +) => { + const patch = history.__knockGuidePatch; + if (!patch) return; + + patch.listeners.delete(listener); + + if (patch.listeners.size === 0) { + history.pushState = patch.originalPushState; + history.replaceState = patch.originalReplaceState; + delete history.__knockGuidePatch; + } +}; + export const guidesApiRootPath = (userId: string | undefined | null) => `/v1/users/${userId}/guides`; @@ -196,10 +270,6 @@ export class KnockGuideClient { ]; private subscribeRetryCount = 0; - // Original history methods to monkey patch, or restore in cleanups. - private pushStateFn: History["pushState"] | undefined; - private replaceStateFn: History["replaceState"] | undefined; - // Guides that are competing to render are "staged" first without rendering // and ranked based on its relative order in the group over a duration of time // to resolve and render the prevailing one. @@ -278,9 +348,24 @@ export class KnockGuideClient { this.clearCounterInterval(); } - async fetch(opts?: { filters?: QueryFilterParams; force?: boolean }) { + async fetch(opts?: { + filters?: QueryFilterParams; + force?: boolean; + }): Promise { this.knock.log("[Guide] .fetch"); - this.knock.failIfNotAuthenticated(); + + // No user to fetch guides for. Return an error status (without caching it, + // so a later authenticated fetch still runs) rather than throwing — guide + // components auto-fire this and a throw crashes the host app. + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] User is not authenticated, skipping fetch"); + return { + status: "error", + error: new Error( + "Not authenticated. Please call `authenticate` first.", + ), + }; + } const queryParams = this.buildQueryParams(opts?.filters); const queryKey = this.formatQueryKey(queryParams); @@ -341,8 +426,20 @@ export class KnockGuideClient { } subscribe() { + // No user to subscribe for; skip silently rather than throwing (this is + // reachable from guide components rendered while unauthenticated). + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] User is not authenticated, skipping subscribe"); + return; + } + + // Re-read the socket from the API client on every subscribe. Re-authenticating + // replaces the API client (and therefore its socket), so a socket captured + // once in the constructor goes stale and reconnects with old credentials. + // Reading it here keeps guide real-time working across re-auth (E12). + this.socket = this.knock.client().socket; if (!this.socket) return; - this.knock.failIfNotAuthenticated(); + this.knock.log("[Guide] Subscribing to real time updates"); // Ensure a live socket connection if not yet connected. @@ -897,6 +994,10 @@ export class KnockGuideClient { // async markAsSeen(guide: GuideData, step: GuideStepData) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] User is not authenticated, skipping markAsSeen"); + return; + } if (step.message.seen_at) return; this.knock.log( @@ -934,6 +1035,13 @@ export class KnockGuideClient { step: GuideStepData, metadata?: GenericData, ) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Guide] User is not authenticated, skipping markAsInteracted", + ); + return; + } + this.knock.log( `[Guide] Marking as interacted (Guide key: ${guide.key}; Step ref:${step.ref})`, ); @@ -966,6 +1074,12 @@ export class KnockGuideClient { } async markAsArchived(guide: GuideData, step: GuideStepData) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Guide] User is not authenticated, skipping markAsArchived", + ); + return; + } if (step.message.archived_at) return; this.knock.log( @@ -1279,31 +1393,13 @@ export class KnockGuideClient { // 2. Listen for hash changes in case it's used for routing. win.addEventListener("hashchange", this.handleLocationChange); - // 3. Monkey-patch history methods to catch programmatic navigation. - const pushStateFn = win.history.pushState; - const replaceStateFn = win.history.replaceState; - - // Use setTimeout to allow the browser state to potentially settle. - win.history.pushState = new Proxy(pushStateFn, { - apply: (target, history, args) => { - Reflect.apply(target, history, args); - setTimeout(() => { - this.handleLocationChange(); - }, 0); - }, - }); - win.history.replaceState = new Proxy(replaceStateFn, { - apply: (target, history, args) => { - Reflect.apply(target, history, args); - setTimeout(() => { - this.handleLocationChange(); - }, 0); - }, - }); - - // 4. Keep refs to the original handlers so we can restore during cleanup. - this.pushStateFn = pushStateFn; - this.replaceStateFn = replaceStateFn; + // 3. Catch programmatic navigation via a shared history patch. This is + // idempotent across guide client instances so a remount doesn't nest + // patches (see `addHistoryLocationListener`). + addHistoryLocationListener( + win.history as PatchedHistory, + this.handleLocationChange, + ); } else { this.knock.log( "[Guide] Unable to access the `window.history` object to detect location changes", @@ -1318,14 +1414,10 @@ export class KnockGuideClient { win.removeEventListener("popstate", this.handleLocationChange); win.removeEventListener("hashchange", this.handleLocationChange); - if (this.pushStateFn) { - win.history.pushState = this.pushStateFn; - this.pushStateFn = undefined; - } - if (this.replaceStateFn) { - win.history.replaceState = this.replaceStateFn; - this.replaceStateFn = undefined; - } + removeHistoryLocationListener( + win.history as PatchedHistory, + this.handleLocationChange, + ); } static getToolbarRunConfigFromUrl() { diff --git a/packages/client/src/clients/messages/index.ts b/packages/client/src/clients/messages/index.ts index cf3701812..4550cfd1f 100644 --- a/packages/client/src/clients/messages/index.ts +++ b/packages/client/src/clients/messages/index.ts @@ -62,6 +62,16 @@ class MessageClient { status: MessageEngagementStatus | "unseen" | "unread" | "unarchived", options?: UpdateMessageStatusOptions, ): Promise { + // The feed's mark methods already guard on auth; this guards the same path + // against direct calls so a stale-rendered cell can't fire a status update + // for a logged-out user. + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Messages] User is not authenticated, skipping batchUpdateStatuses", + ); + return []; + } + // Metadata is only required for the "interacted" status const additionalPayload = status === "interacted" && options ? { metadata: options.metadata } : {}; diff --git a/packages/client/src/clients/ms-teams/index.ts b/packages/client/src/clients/ms-teams/index.ts index e0da5a5c4..647a0202e 100644 --- a/packages/client/src/clients/ms-teams/index.ts +++ b/packages/client/src/clients/ms-teams/index.ts @@ -18,6 +18,16 @@ class MsTeamsClient { } async authCheck({ tenant: tenantId, knockChannelId }: AuthCheckInput) { + // Without a user we can't check a tenant's connection. Return a "not + // connected" shape (never an error) so status hooks land on "disconnected" + // rather than firing a request to `/v1/providers/ms-teams/.../auth_check`. + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MsTeams] User is not authenticated, skipping authCheck", + ); + return { connection: { ok: false } }; + } + const result = await this.instance.client().makeRequest({ method: "GET", url: `/v1/providers/ms-teams/${knockChannelId}/auth_check`, @@ -36,6 +46,13 @@ class MsTeamsClient { async getTeams( input: GetMsTeamsTeamsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MsTeams] User is not authenticated, skipping getTeams", + ); + return { ms_teams_teams: [], skip_token: null }; + } + const { knockChannelId, tenant: tenantId } = input; const queryOptions = input.queryOptions || {}; @@ -62,6 +79,13 @@ class MsTeamsClient { async getChannels( input: GetMsTeamsChannelsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MsTeams] User is not authenticated, skipping getChannels", + ); + return { ms_teams_channels: [] }; + } + const { knockChannelId, teamId, tenant: tenantId } = input; const queryOptions = input.queryOptions || {}; diff --git a/packages/client/src/clients/slack/index.ts b/packages/client/src/clients/slack/index.ts index 1ad308db7..632c02637 100644 --- a/packages/client/src/clients/slack/index.ts +++ b/packages/client/src/clients/slack/index.ts @@ -13,6 +13,16 @@ class SlackClient { } async authCheck({ tenant, knockChannelId }: AuthCheckInput) { + // Without a user we can't check a tenant's connection. Return a "not + // connected" shape (never an error) so status hooks land on "disconnected" + // rather than firing a request to `/v1/providers/slack/.../auth_check`. + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[Slack] User is not authenticated, skipping authCheck", + ); + return { connection: { ok: false } }; + } + const result = await this.instance.client().makeRequest({ method: "GET", url: `/v1/providers/slack/${knockChannelId}/auth_check`, @@ -31,6 +41,13 @@ class SlackClient { async getChannels( input: GetSlackChannelsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[Slack] User is not authenticated, skipping getChannels", + ); + return { slack_channels: [], next_cursor: null }; + } + const { knockChannelId, tenant } = input; const queryOptions = input.queryOptions || {}; diff --git a/packages/client/src/interfaces.ts b/packages/client/src/interfaces.ts index fdf065ab0..d12350cc1 100644 --- a/packages/client/src/interfaces.ts +++ b/packages/client/src/interfaces.ts @@ -60,6 +60,24 @@ export interface AuthenticateOptions { identificationStrategy?: "inline" | "skip"; } +/** + * Whether a `Knock` instance currently has a user identity to act on behalf of. + * When `unauthenticated`, the instance is fully quiescent: no network requests + * and no real-time socket activity occur until `authenticate` is called. + */ +export type KnockAuthStatus = "authenticated" | "unauthenticated"; + +/** + * The shape of the subscribable auth-state store exposed on `knock.authStore`. + * Subsystems and React hooks can subscribe to this to react to auth transitions + * (login / logout / user switch) without polling `isAuthenticated()`. + */ +export interface KnockAuthState { + status: KnockAuthStatus; + userId: UserId; + userToken: string | undefined; +} + export interface BulkOperation { id: string; name: string; diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index 06d881da4..3f1ba3dd9 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -1,3 +1,5 @@ +import { Store } from "@tanstack/store"; + import ApiClient from "./api"; import FeedClient from "./clients/feed"; import MessageClient from "./clients/messages"; @@ -8,6 +10,8 @@ import SlackClient from "./clients/slack"; import UserClient from "./clients/users"; import { AuthenticateOptions, + KnockAuthState, + KnockAuthStatus, KnockOptions, LogLevel, UserId, @@ -35,6 +39,18 @@ class Knock { readonly user = new UserClient(this); readonly messages = new MessageClient(this); + /** + * A subscribable store describing whether this instance is currently + * authenticated. Fired on `authenticate()` and `logout()`. Subsystems (feed, + * guides, Slack/Teams status, push registration) and React hooks can + * subscribe to react to auth transitions without polling `isAuthenticated()`. + */ + readonly authStore = new Store({ + status: "unauthenticated", + userId: undefined, + userToken: undefined, + }); + constructor( readonly apiKey: string, options: KnockOptions = {}, @@ -90,13 +106,14 @@ class Knock { const currentApiClient = this.apiClient; const userId = this.getUserId(userIdOrUserWithProperties); const identificationStrategy = options?.identificationStrategy || "inline"; + const credentialsChanged = + this.userId !== userId || this.userToken !== userToken; - // If we've previously been initialized and the values have now changed, then we - // need to reinitialize any stateful connections we have - if ( - currentApiClient && - (this.userId !== userId || this.userToken !== userToken) - ) { + // If the credentials have changed and we have stateful connections to + // rewire, then we need to reinitialize them. This covers both an in-place + // user/token switch (live API client) and re-authenticating after a + // `logout()` cleared the API client but left feed instances in place. + if (credentialsChanged && (currentApiClient || this.feeds.hasInstances())) { this.log("userId or userToken changed; reinitializing connections"); this.feeds.teardownInstances(); this.teardown(); @@ -124,6 +141,11 @@ class Knock { this.log("Reinitialized real-time connections"); } + // Notify subscribers of the (possibly changed) auth state. Done before the + // inline identify below so subscribers observe the new credentials + // regardless of identification strategy. + this.syncAuthState(); + // We explicitly skip the inline identification if the strategy is set to "skip" if (identificationStrategy === "skip") { this.log("Skipping inline user identification"); @@ -166,6 +188,44 @@ class Knock { return checkUserToken ? !!(this.userId && this.userToken) : !!this.userId; } + /** The current authentication status of this instance. */ + get authStatus(): KnockAuthStatus { + return this.authStore.state.status; + } + + /** + * Clears the current authentication and tears down all stateful connections + * (real-time feed channels, the socket, the token-expiration timer, and the + * page-visibility listener). + * + * After `logout()` the instance is fully quiescent — no network or socket + * activity occurs until `authenticate()` is called again. The API client is + * dropped so that a fresh one is lazily constructed on next use rather than + * holding an open socket + document listener while logged out. + */ + logout() { + this.log("Logging out and tearing down connections"); + + // Leave any joined feed channels before we drop the socket. + this.feeds.teardownInstances(); + + // Disconnect the socket, clear the token-expiration timer, and remove the + // page-visibility listener. + this.teardown(); + + // Clear credentials. + this.userId = undefined; + this.userToken = undefined; + + // Drop the API client so a fresh one (with no socket/listener) is lazily + // constructed only if and when the instance is used again. Feed instances + // are left in place and get rewired by a subsequent `authenticate()`. + this.apiClient = null; + + // Notify subscribers that we are now unauthenticated. + this.syncAuthState(); + } + // Used to teardown any connected instances teardown() { if (this.tokenExpirationTimer) { @@ -174,6 +234,28 @@ class Knock { this.apiClient?.teardown(); } + /** + * Recomputes the auth status from the current credentials and pushes it into + * the subscribable auth store (no-op if nothing changed). + */ + private syncAuthState() { + const status: KnockAuthStatus = this.isAuthenticated() + ? "authenticated" + : "unauthenticated"; + + this.authStore.setState((prev) => { + if ( + prev.status === status && + prev.userId === this.userId && + prev.userToken === this.userToken + ) { + return prev; + } + + return { status, userId: this.userId, userToken: this.userToken }; + }); + } + log(message: string, force = false) { if (this.logLevel === "debug" || force) { console.log(`[Knock] ${message}`); diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index 4ba7ff30f..0f7fa85a3 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1494,4 +1494,61 @@ describe("Feed", () => { } }); }); + + describe("Unauthenticated mutations are quiescent", () => { + const getUnauthenticatedSetup = () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + return { knock, mockApiClient, feed }; + }; + + const mutations: Array<[string, (feed: Feed) => Promise]> = [ + ["markAsSeen", (f) => f.markAsSeen(createUnreadFeedItem())], + ["markAsUnseen", (f) => f.markAsUnseen(createUnreadFeedItem())], + ["markAsRead", (f) => f.markAsRead(createUnreadFeedItem())], + ["markAsUnread", (f) => f.markAsUnread(createUnreadFeedItem())], + ["markAsInteracted", (f) => f.markAsInteracted(createUnreadFeedItem())], + ["markAsArchived", (f) => f.markAsArchived(createUnreadFeedItem())], + ["markAsUnarchived", (f) => f.markAsUnarchived(createUnreadFeedItem())], + ["markAllAsSeen", (f) => f.markAllAsSeen()], + ["markAllAsRead", (f) => f.markAllAsRead()], + ["markAllAsArchived", (f) => f.markAllAsArchived()], + ["markAllReadAsArchived", (f) => f.markAllReadAsArchived()], + ["fetchNextPage", (f) => f.fetchNextPage()], + ]; + + test.each(mutations)( + "%s is a no-op that never touches the network", + async (_name, invoke) => { + const { mockApiClient, feed } = getUnauthenticatedSetup(); + + const result = await invoke(feed); + + expect(result).toBeUndefined(); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }, + ); + + test("skips the optimistic store update, leaving badge counts intact", async () => { + const { mockApiClient, feed } = getUnauthenticatedSetup(); + const item = createUnreadFeedItem(); + + // Seed badge counts so we can assert they are not optimistically changed. + feed.getState().setMetadata({ + total_count: 1, + unread_count: 1, + unseen_count: 1, + }); + + await feed.markAsSeen(item); + + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + expect(feed.getState().metadata.unseen_count).toBe(1); + }); + }); }); diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 1895324f9..2fec52bcf 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -251,7 +251,7 @@ describe("KnockGuideClient", () => { await client.fetch(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockKnock.user.getGuides).toHaveBeenCalledWith( channelId, expect.objectContaining({ @@ -323,7 +323,7 @@ describe("KnockGuideClient", () => { const client = new KnockGuideClient(mockKnock, channelId); await client.fetch(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockStore.setState).toHaveBeenCalledWith(expect.any(Function)); // Get the last setState call and execute its function @@ -400,7 +400,7 @@ describe("KnockGuideClient", () => { ); client.subscribe(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockSocket.channel).toHaveBeenCalledWith( `guides:${channelId}`, expect.objectContaining({ @@ -596,6 +596,137 @@ describe("KnockGuideClient", () => { }); }); + describe("Unauthenticated quiescence", () => { + const unauthGuide = { + __typename: "Guide", + channel_id: channelId, + id: "guide_123", + key: "test_guide", + type: "test", + semver: "1.0.0", + active: true, + steps: [], + activation_url_rules: [], + activation_url_patterns: [], + bypass_global_group_limit: false, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as unknown as KnockGuide; + + const unauthStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + } as unknown as KnockGuideStep; + + test("fetch returns an error status without calling the API or throwing", async () => { + // Regression: the guide client used to throw `failIfNotAuthenticated`, + // which crashed host apps that rendered Guides before a user was set. + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.fetch(); + + expect(result.status).toBe("error"); + expect(mockKnock.user.getGuides).not.toHaveBeenCalled(); + }); + + test("subscribe no-ops without joining a channel", () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const mockSocket = { + channel: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + connect: vi.fn(), + }; + mockApiClient.socket = mockSocket as unknown as Socket; + vi.mocked(mockKnock.client).mockReturnValue(mockApiClient as ApiClient); + + const client = new KnockGuideClient(mockKnock, channelId); + client.subscribe(); + + expect(mockSocket.channel).not.toHaveBeenCalled(); + }); + + test("markAsSeen no-ops without calling the engagement API", async () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.markAsSeen(unauthGuide, unauthStep); + + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsInteracted no-ops without calling the engagement API", async () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.markAsInteracted(unauthGuide, unauthStep); + + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsArchived no-ops without calling the engagement API", async () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.markAsArchived(unauthGuide, unauthStep); + + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + }); + + describe("subscribe re-reads the socket from the api client (E12)", () => { + const makeMockSocket = () => ({ + channel: vi.fn().mockReturnValue({ + join: vi.fn().mockReturnValue({ + receive: vi.fn().mockReturnValue({ + receive: vi.fn().mockReturnValue({ receive: vi.fn() }), + }), + }), + on: vi.fn(), + off: vi.fn(), + leave: vi.fn(), + state: "closed", + }), + isConnected: vi.fn().mockReturnValue(true), + connect: vi.fn(), + }); + + test("uses the current socket after re-authentication, not the one captured at construction", () => { + const oldSocket = makeMockSocket(); + const newSocket = makeMockSocket(); + + // The constructor captures the old socket via `knock.client().socket`. + mockApiClient.socket = oldSocket as unknown as Socket; + vi.mocked(mockKnock.client).mockReturnValue(mockApiClient as ApiClient); + const client = new KnockGuideClient(mockKnock, channelId); + + // Simulate a re-authentication that swaps in a new socket on the client. + mockApiClient.socket = newSocket as unknown as Socket; + + client.subscribe(); + + expect(newSocket.channel).toHaveBeenCalledWith( + `guides:${channelId}`, + expect.anything(), + ); + expect(oldSocket.channel).not.toHaveBeenCalled(); + }); + }); + describe("guide operations", () => { const mockStep = { ref: "step_1", @@ -3686,6 +3817,56 @@ describe("KnockGuideClient", () => { expect(windowWithHistory.history.replaceState).toBe(originalReplaceState); }); + test("shares one history patch across instances and restores only when the last is cleaned up", () => { + const originalPushState = vi.fn(); + const originalReplaceState = vi.fn(); + + vi.stubGlobal("window", { + ...mockWindow, + history: { + pushState: originalPushState, + replaceState: originalReplaceState, + }, + }); + + const windowWithHistory = window as unknown as { + history: { pushState: unknown; replaceState: unknown }; + }; + + // Two guide clients patch the same window (as happens across a remount: + // the new client is constructed while the old one is still tearing down). + const clientA = new KnockGuideClient( + mockKnock, + channelId, + {}, + { trackLocationFromWindow: true }, + ); + const clientB = new KnockGuideClient( + mockKnock, + channelId, + {}, + { trackLocationFromWindow: true }, + ); + + // History is patched (not the originals) while either client is active. + expect(windowWithHistory.history.pushState).not.toBe(originalPushState); + expect(windowWithHistory.history.replaceState).not.toBe( + originalReplaceState, + ); + + // Cleaning up one client must NOT restore the originals — the other still + // needs location tracking (the pre-fix per-instance patch clobbered it). + clientA.cleanup(); + expect(windowWithHistory.history.pushState).not.toBe(originalPushState); + expect(windowWithHistory.history.replaceState).not.toBe( + originalReplaceState, + ); + + // Cleaning up the last client restores the originals exactly. + clientB.cleanup(); + expect(windowWithHistory.history.pushState).toBe(originalPushState); + expect(windowWithHistory.history.replaceState).toBe(originalReplaceState); + }); }); describe("private methods", () => { @@ -3912,7 +4093,7 @@ describe("KnockGuideClient", () => { await client.fetch({ filters: { type: "tooltip" } }); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockKnock.user.getGuides).toHaveBeenCalledWith( channelId, expect.objectContaining({ diff --git a/packages/client/test/clients/messages/messages.test.ts b/packages/client/test/clients/messages/messages.test.ts index 23ea85644..c47c166c3 100644 --- a/packages/client/test/clients/messages/messages.test.ts +++ b/packages/client/test/clients/messages/messages.test.ts @@ -747,4 +747,18 @@ describe("MessageClient", () => { ); }); }); + + describe("Unauthenticated quiescence", () => { + test("batchUpdateStatuses returns an empty array without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + + const result = await knock.messages.batchUpdateStatuses( + ["message_123", "message_456"], + "seen", + ); + + expect(result).toEqual([]); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/clients/ms-teams/ms-teams.test.ts b/packages/client/test/clients/ms-teams/ms-teams.test.ts index 0aae31f58..54eb23c63 100644 --- a/packages/client/test/clients/ms-teams/ms-teams.test.ts +++ b/packages/client/test/clients/ms-teams/ms-teams.test.ts @@ -630,4 +630,46 @@ describe("Microsoft Teams Client", () => { } }); }); + + describe("Unauthenticated quiescence", () => { + test("authCheck returns a disconnected shape without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + const client = new MsTeamsClient(knock); + + const result = await client.authCheck({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ connection: { ok: false } }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("getTeams returns an empty list without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); + const client = new MsTeamsClient(knock); + + const result = await client.getTeams({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ ms_teams_teams: [], skip_token: null }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("getChannels returns an empty list without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); + const client = new MsTeamsClient(knock); + + const result = await client.getChannels({ + tenant: "tenant_123", + knockChannelId: "channel_123", + teamId: "team_123", + }); + + expect(result).toEqual({ ms_teams_channels: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/clients/slack/slack.test.ts b/packages/client/test/clients/slack/slack.test.ts index 0c5871c5f..cb1fd8310 100644 --- a/packages/client/test/clients/slack/slack.test.ts +++ b/packages/client/test/clients/slack/slack.test.ts @@ -510,4 +510,32 @@ describe("Slack Client", () => { } }); }); + + describe("Unauthenticated quiescence", () => { + test("authCheck returns a disconnected shape without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + const client = new SlackClient(knock); + + const result = await client.authCheck({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ connection: { ok: false } }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("getChannels returns an empty list without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); + const client = new SlackClient(knock); + + const result = await client.getChannels({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ slack_channels: [], next_cursor: null }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/knock.test.ts b/packages/client/test/knock.test.ts index dd5660346..be5f9b18a 100644 --- a/packages/client/test/knock.test.ts +++ b/packages/client/test/knock.test.ts @@ -779,4 +779,162 @@ describe("Knock Client", () => { expect(knock.isAuthenticated(true)).toBe(true); }); }); + + describe("Auth state store", () => { + test("starts unauthenticated", () => { + const knock = new Knock("pk_test_12345"); + + expect(knock.authStatus).toBe("unauthenticated"); + expect(knock.authStore.state).toEqual({ + status: "unauthenticated", + userId: undefined, + userToken: undefined, + }); + }); + + test("transitions to authenticated on authenticate()", () => { + const knock = new Knock("pk_test_12345"); + + knock.authenticate("user_123", "token_456"); + + expect(knock.authStatus).toBe("authenticated"); + expect(knock.authStore.state).toEqual({ + status: "authenticated", + userId: "user_123", + userToken: "token_456", + }); + }); + + test("notifies subscribers on login", () => { + const knock = new Knock("pk_test_12345"); + const listener = vi.fn(); + const unsubscribe = knock.authStore.subscribe(listener); + + knock.authenticate("user_123", "token_456"); + + expect(listener).toHaveBeenCalled(); + expect(knock.authStore.state.status).toBe("authenticated"); + unsubscribe(); + }); + + test("keeps a stable state reference when re-authenticating with identical credentials", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const stateBefore = knock.authStore.state; + + // Re-authenticating with the same credentials should not churn the store + // reference, so React `useStore` selectors don't re-render needlessly. + knock.authenticate("user_123", "token_456"); + + expect(knock.authStore.state).toBe(stateBefore); + }); + + test("updates the store userId on a user switch", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + // Realize the api client so the switch takes the reinitialize path. + knock.client(); + + knock.authenticate("user_789", "token_456"); + + expect(knock.authStatus).toBe("authenticated"); + expect(knock.authStore.state.userId).toBe("user_789"); + }); + }); + + describe("Logout", () => { + test("clears credentials and marks the instance unauthenticated", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + expect(knock.isAuthenticated()).toBe(true); + + knock.logout(); + + expect(knock.userId).toBeUndefined(); + expect(knock.userToken).toBeUndefined(); + expect(knock.isAuthenticated()).toBe(false); + expect(knock.authStatus).toBe("unauthenticated"); + }); + + test("tears down feed instances and connections", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const teardownInstancesSpy = vi.spyOn(knock.feeds, "teardownInstances"); + const teardownSpy = vi.spyOn(knock, "teardown"); + + knock.logout(); + + expect(teardownInstancesSpy).toHaveBeenCalled(); + expect(teardownSpy).toHaveBeenCalled(); + }); + + test("clears the token expiration timer", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + knock["tokenExpirationTimer"] = setTimeout(() => {}, 1000); + + knock.logout(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + test("drops the api client so a fresh one is created on next use", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const client1 = knock.client(); + knock.logout(); + const client2 = knock.client(); + + expect(client1).not.toBe(client2); + }); + + test("notifies auth-state subscribers", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const listener = vi.fn(); + const unsubscribe = knock.authStore.subscribe(listener); + + knock.logout(); + + expect(listener).toHaveBeenCalled(); + expect(knock.authStore.state.status).toBe("unauthenticated"); + unsubscribe(); + }); + + test("is safe to call when never authenticated", () => { + const knock = new Knock("pk_test_12345"); + + expect(() => knock.logout()).not.toThrow(); + expect(knock.authStatus).toBe("unauthenticated"); + }); + }); + + describe("Logout then re-authenticate", () => { + test("reinitializes existing feed instances after logout", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + // Create a feed instance so there is real-time state to rewire. + knock.feeds.initialize("01234567-89ab-cdef-0123-456789abcdef"); + expect(knock.feeds.hasInstances()).toBe(true); + + knock.logout(); + + const reinitializeSpy = vi.spyOn(knock.feeds, "reinitializeInstances"); + + // Re-authenticating must rewire the surviving feed instances even though + // `logout()` dropped the api client. + knock.authenticate("user_123", "token_456"); + + expect(reinitializeSpy).toHaveBeenCalled(); + expect(knock.isAuthenticated()).toBe(true); + }); + }); }); From 4786d7fdd3158c99cad6fbeabaaa2140be3a09ca Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:25:33 -0500 Subject: [PATCH 02/14] feat(react-core): add enabled prop to KnockProvider Implement `enabled` (default true) as credential-nulling through useAuthenticatedKnockClient: when false the client is created but left unauthenticated and fully quiescent while children still render. A fresh client is built on every enable/disable transition so enabling remounts the feed subtree (and refetches) and disabling clears the previous user's stores. Also guard useFeedSettings against firing GET /v1/users/undefined/.../settings for an unauthenticated user, and tear the client down on provider unmount (StrictMode-safe via a dispose/re-init flag) instead of leaking the socket, token timer, and page-visibility listener. --- .changeset/knockprovider-enabled-prop.md | 26 +++++ .../modules/core/context/KnockProvider.tsx | 23 ++++ .../core/hooks/useAuthenticatedKnockClient.ts | 103 +++++++++++++++--- .../src/modules/feed/hooks/useFeedSettings.ts | 9 ++ .../test/core/KnockProvider.test.tsx | 75 +++++++++++++ .../core/useAuthenticatedKnockClient.test.ts | 103 ++++++++++++++++++ .../test/feed/useFeedSettings.test.ts | 28 ++++- 7 files changed, 349 insertions(+), 18 deletions(-) create mode 100644 .changeset/knockprovider-enabled-prop.md diff --git a/.changeset/knockprovider-enabled-prop.md b/.changeset/knockprovider-enabled-prop.md new file mode 100644 index 000000000..c07d8152a --- /dev/null +++ b/.changeset/knockprovider-enabled-prop.md @@ -0,0 +1,26 @@ +--- +"@knocklabs/react-core": minor +"@knocklabs/react": minor +"@knocklabs/react-native": minor +"@knocklabs/expo": minor +--- + +Add an `enabled` prop to `KnockProvider` (and an `enabled` option to `useAuthenticatedKnockClient`). + +When `enabled` is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no identify, no network requests, and no real-time socket activity. Flipping it to `true` authenticates and mounts everything (like a login); flipping it back to `false` tears everything down and clears the client's stores (like a logout). It defaults to `true`, so existing usage is unchanged. + +This is the recommended replacement for conditionally mounting `KnockProvider`, and the canonical way to defer activity until you have a complete identity — e.g. an enhanced-security user token that loads asynchronously: + +```tsx + +``` + +Also fixed while here: + +- `useFeedSettings` no longer fires `GET /v1/users/undefined/feeds/.../settings` for an unauthenticated user. +- `KnockProvider` now tears down its Knock client (socket, token-expiration timer, and page-visibility listener) on unmount instead of leaking it. diff --git a/packages/react-core/src/modules/core/context/KnockProvider.tsx b/packages/react-core/src/modules/core/context/KnockProvider.tsx index 084475889..915f80cbe 100644 --- a/packages/react-core/src/modules/core/context/KnockProvider.tsx +++ b/packages/react-core/src/modules/core/context/KnockProvider.tsx @@ -26,6 +26,26 @@ export type KnockProviderProps = { i18n?: I18nContent; logLevel?: LogLevel; branch?: string; + /** + * When `false`, render children but keep the Knock client unauthenticated and + * fully quiescent — no identify, network, or socket activity. Flipping it to + * `true` authenticates and mounts everything (like a login); flipping it back + * to `false` tears everything down (like a logout). Defaults to `true`. + * + * This is the recommended way to gate the provider on a complete identity — + * e.g. an enhanced-security token that loads asynchronously — instead of + * conditionally mounting `KnockProvider`: + * + * ```tsx + * + * ``` + */ + enabled?: boolean; } & ( | { /** @@ -66,6 +86,7 @@ export const KnockProvider: React.FC> = ({ i18n, identificationStrategy, branch, + enabled, ...props }) => { const userIdOrUserWithProperties = props?.user || props?.userId; @@ -79,6 +100,7 @@ export const KnockProvider: React.FC> = ({ logLevel, identificationStrategy, branch, + enabled, }), [ host, @@ -87,6 +109,7 @@ export const KnockProvider: React.FC> = ({ logLevel, identificationStrategy, branch, + enabled, ], ); diff --git a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts index 80382eac0..813bce159 100644 --- a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts +++ b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts @@ -22,7 +22,28 @@ function authenticateWithOptions( } export type AuthenticatedKnockClientOptions = KnockOptions & - AuthenticateOptions; + AuthenticateOptions & { + /** + * When `false`, the Knock client is created but left **unauthenticated and + * fully quiescent** — no identify, no network requests, and no real-time + * socket activity — while children still render. Flipping it back to `true` + * authenticates and mounts everything, like a login; flipping it to `false` + * tears everything down, like a logout. + * + * The canonical use is to defer all activity until you have a complete + * identity — e.g. an enhanced-security user token that loads asynchronously + * (without this, a present `userId` with a not-yet-loaded token fires 401s): + * + * ```tsx + * useAuthenticatedKnockClient(apiKey, { id: userId }, userToken, { + * enabled: Boolean(userId && userToken), + * }); + * ``` + * + * Defaults to `true`. + */ + enabled?: boolean; + }; /** * @deprecated Passing `userId` as a `string` is deprecated and will be removed in a future version. @@ -52,27 +73,46 @@ function useAuthenticatedKnockClient( ) { const knockRef = React.useRef(undefined); - const stableOptions = useStableOptions(options); + // Tracks whether the current client was torn down by a prior effect cleanup + // (e.g. a React StrictMode simulated unmount) so we can rebuild it. + const disposedRef = React.useRef(false); + // Bumping this forces the memo below to build a fresh client after a teardown. + const [generation, setGeneration] = React.useState(0); + + const { enabled = true, ...authenticateOptions } = options; + + const stableOptions = useStableOptions(authenticateOptions); const stableUserIdOrObject = useStableOptions(userIdOrUserWithProperties); - return React.useMemo(() => { + const knock = React.useMemo(() => { + // When disabled, behave as if no user was provided: null the credentials so + // a fresh, unauthenticated client is created and stays fully quiescent. This + // makes `enabled: false → true` behave like a login (and the reverse like a + // logout) through the same code path headless hook consumers already use. + const activeUserIdOrObject = enabled ? stableUserIdOrObject : undefined; + const activeUserToken = enabled ? userToken : undefined; + const userId = - typeof stableUserIdOrObject === "string" - ? stableUserIdOrObject - : stableUserIdOrObject?.id; + typeof activeUserIdOrObject === "string" + ? activeUserIdOrObject + : activeUserIdOrObject?.id; const currentKnock = knockRef.current; - // If the userId and the userToken changes then just reauth + // If we already have an authenticated client and only the credentials + // changed, just reauthenticate in place. (A userId change still remounts the + // feed subtree downstream because `feedProviderKey` includes the userId.) if ( + enabled && currentKnock && currentKnock.isAuthenticated() && - (currentKnock.userId !== userId || currentKnock.userToken !== userToken) + (currentKnock.userId !== userId || + currentKnock.userToken !== activeUserToken) ) { authenticateWithOptions( currentKnock, - stableUserIdOrObject, - userToken, + activeUserIdOrObject, + activeUserToken, stableOptions, ); return currentKnock; @@ -82,7 +122,10 @@ function useAuthenticatedKnockClient( currentKnock.teardown(); } - // Otherwise instantiate a new Knock client + // Otherwise instantiate a new Knock client. Creating a fresh instance on + // both the enable and disable transitions guarantees a new context identity + // (so the feed subtree remounts and refetches) and empty stores when + // disabling (no stale notifications/PII linger after a logout). const knock = new Knock(apiKey, { host: stableOptions.host, logLevel: stableOptions.logLevel, @@ -91,14 +134,46 @@ function useAuthenticatedKnockClient( authenticateWithOptions( knock, - stableUserIdOrObject, - userToken, + activeUserIdOrObject, + activeUserToken, stableOptions, ); knockRef.current = knock; return knock; - }, [apiKey, stableUserIdOrObject, userToken, stableOptions]); + // `generation` is included so a post-teardown revival (in the effect below) + // rebuilds the client; it is intentionally not read in the memo body. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + apiKey, + enabled, + stableUserIdOrObject, + userToken, + stableOptions, + generation, + ]); + + // Tear the client down when the provider unmounts so we don't leak the socket, + // the token-expiration timer, or the page-visibility listener. Uses empty deps + // so it only fires on real unmount (transition teardown is handled in the memo + // above, and firing here on every `knock` change would double-tear-down). + // + // StrictMode double-invokes effects: the simulated unmount tears the client + // down, so on the simulated remount we rebuild a fresh one (mirrors the + // dispose/re-init pattern in `useNotifications`). + React.useEffect(() => { + if (disposedRef.current) { + disposedRef.current = false; + setGeneration((g) => g + 1); + } + + return () => { + disposedRef.current = true; + knockRef.current?.teardown(); + }; + }, []); + + return knock; } export default useAuthenticatedKnockClient; diff --git a/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts b/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts index 0f4013e4f..c694d7730 100644 --- a/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts +++ b/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts @@ -19,6 +19,15 @@ function useFeedSettings(feedClient: Feed): { useEffect(() => { async function getSettings() { const knock = feedClient.knock; + + // Skip the branding fetch when there's no authenticated user — otherwise + // we'd fire `GET /v1/users/undefined/feeds/.../settings`. When the user + // authenticates the feed subtree remounts (its `feedProviderKey` includes + // the userId), which re-runs this effect with a real user. + if (!knock.isAuthenticated()) { + return; + } + const apiClient = knock.client(); const feedSettingsPath = `/v1/users/${knock.userId}/feeds/${feedClient.feedId}/settings`; setIsLoading(true); diff --git a/packages/react-core/test/core/KnockProvider.test.tsx b/packages/react-core/test/core/KnockProvider.test.tsx index 7cfc4376a..f0d6a05a3 100644 --- a/packages/react-core/test/core/KnockProvider.test.tsx +++ b/packages/react-core/test/core/KnockProvider.test.tsx @@ -331,4 +331,79 @@ describe("KnockProvider", () => { ); }); }); + + describe("enabled prop", () => { + test("renders children but does not authenticate when enabled is false", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
API Key: {knock.apiKey}
; + }; + + const { getByTestId } = render( + + + , + ); + + // Children still render... + expect(getByTestId("consumer-msg")).toHaveTextContent( + "API Key: test_api_key", + ); + // ...but no identify (or any user request) fires while disabled. + expect(mockApiClient.makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + }), + ); + }); + + test("authenticates once enabled flips to true", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
User Id: {knock.userId}
; + }; + + const { rerender } = render( + + + , + ); + + expect(mockApiClient.makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + }), + ); + + mockApiClient.makeRequest.mockClear(); + + rerender( + + + , + ); + + expect(mockApiClient.makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + data: { name: "John Doe" }, + }), + ); + }); + }); }); diff --git a/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts b/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts index 20f91f80a..7cea3dee4 100644 --- a/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts +++ b/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts @@ -294,4 +294,107 @@ describe("useAuthenticatedKnockClient", () => { expect(result.current.branch).toEqual(TEST_BRANCH_SLUG); }); + + describe("enabled option", () => { + it("creates an unauthenticated, quiescent client when enabled is false", () => { + const { result } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { + ...defaultProps, + userToken: "token_123", + options: { enabled: false }, + }, + }, + ); + + expect(result.current).toBeInstanceOf(Knock); + expect(result.current.isAuthenticated()).toBe(false); + expect(result.current.userId).toBeUndefined(); + }); + + it("authenticates with a fresh instance when enabled flips true (like a login)", () => { + const { result, rerender } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { + ...defaultProps, + userToken: "token_123", + options: { enabled: false }, + }, + }, + ); + + const disabledInstance = result.current; + expect(disabledInstance.isAuthenticated()).toBe(false); + + rerender({ + ...defaultProps, + userToken: "token_123", + options: { enabled: true }, + }); + + // A brand-new instance is required so the feed subtree remounts and refetches. + expect(result.current).not.toBe(disabledInstance); + expect(result.current.isAuthenticated()).toBe(true); + expect(result.current.userId).toEqual("user_123"); + }); + + it("tears down and creates a fresh unauthenticated instance when enabled flips false (like a logout)", () => { + const { result, rerender } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { + ...defaultProps, + userToken: "token_123", + options: { enabled: true }, + }, + }, + ); + + const enabledInstance = result.current; + expect(enabledInstance.isAuthenticated()).toBe(true); + const teardownSpy = vi.spyOn(enabledInstance, "teardown"); + + rerender({ + ...defaultProps, + userToken: "token_123", + options: { enabled: false }, + }); + + expect(teardownSpy).toHaveBeenCalled(); + // Fresh instance guarantees the previous user's stores don't linger. + expect(result.current).not.toBe(enabledInstance); + expect(result.current.isAuthenticated()).toBe(false); + }); + + it("defaults to enabled (authenticated) when the option is omitted", () => { + const { result } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { ...defaultProps, userToken: "token_123" }, + }, + ); + + expect(result.current.isAuthenticated()).toBe(true); + }); + }); + + it("tears down the client on unmount", () => { + const { result, unmount } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { initialProps: { ...defaultProps, userToken: "token_123" } }, + ); + + const teardownSpy = vi.spyOn(result.current, "teardown"); + + unmount(); + + expect(teardownSpy).toHaveBeenCalled(); + }); }); diff --git a/packages/react-core/test/feed/useFeedSettings.test.ts b/packages/react-core/test/feed/useFeedSettings.test.ts index fd56b2aa8..edc7ad69a 100644 --- a/packages/react-core/test/feed/useFeedSettings.test.ts +++ b/packages/react-core/test/feed/useFeedSettings.test.ts @@ -3,12 +3,17 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import useFeedSettings from "../../src/modules/feed/hooks/useFeedSettings"; -import { createMockFeed, mockNetworkSuccess } from "../test-utils/mocks"; +import { + authenticateKnock, + createMockFeed, + mockNetworkSuccess, +} from "../test-utils/mocks"; describe("useFeedSettings", () => { it("fetches and returns feed settings", async () => { // Arrange: create a mock feed and stub network response - const { feed, mockApiClient } = createMockFeed("feed_123"); + const { feed, knock, mockApiClient } = createMockFeed("feed_123"); + authenticateKnock(knock); const fakeSettings = { features: { @@ -36,7 +41,8 @@ describe("useFeedSettings", () => { }); it("handles api error by returning null settings", async () => { - const { feed, mockApiClient } = createMockFeed("feed_123"); + const { feed, knock, mockApiClient } = createMockFeed("feed_123"); + authenticateKnock(knock); mockApiClient.makeRequest.mockResolvedValue({ statusCode: "error", @@ -57,7 +63,8 @@ describe("useFeedSettings", () => { }); it("leaves settings null when a success response is missing the features payload", async () => { - const { feed, mockApiClient } = createMockFeed("feed_123"); + const { feed, knock, mockApiClient } = createMockFeed("feed_123"); + authenticateKnock(knock); // A degraded connection (captive portal / proxy) can return a 200 whose // body is not the feed settings object. We must not fabricate a default @@ -74,4 +81,17 @@ describe("useFeedSettings", () => { expect(result.current.settings).toBeNull(); }); + + it("does not fetch settings when the user is unauthenticated", async () => { + // No `authenticateKnock` here: the feed's knock has no user. + const { feed, mockApiClient } = createMockFeed("feed_123"); + + const { result } = renderHook(() => + useFeedSettings(feed as unknown as Feed), + ); + + expect(result.current.loading).toBe(false); + expect(result.current.settings).toBeNull(); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); }); From f7cd83ac548b4bc9684347438adb95973b49c179 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:26:16 -0500 Subject: [PATCH 03/14] feat(react-core, expo): react to authentication changes Add useKnockAuthState(knock), which subscribes to the client's authStore and re-renders on login, logout, or a user switch. Wire it into the integrations that previously latched their state: - Slack/MS Teams connection status resets and re-runs authCheck when the authenticated user changes, and the provider keys now include the userId so a user switch reliably re-renders consumers. - Expo autoRegister waits for an authenticated user before registering a push token, deferring the OS permission prompt for logged-out users and re-registering on sign-in; a notification tapped while logged out no longer fires a message-status update. --- ...-knock-auth-state-reactive-integrations.md | 12 ++++++ .../KnockExpoPushNotificationProvider.tsx | 21 ++++++++-- ...KnockExpoPushNotificationProvider.test.tsx | 37 ++++++++++++++++++ packages/react-core/src/index.ts | 1 + .../src/modules/core/hooks/index.ts | 1 + .../modules/core/hooks/useKnockAuthState.ts | 21 ++++++++++ packages/react-core/src/modules/core/index.ts | 1 + packages/react-core/src/modules/core/utils.ts | 8 +++- .../ms-teams/context/KnockMsTeamsProvider.tsx | 1 + .../hooks/useMsTeamsConnectionStatus.ts | 16 +++++++- .../slack/context/KnockSlackProvider.tsx | 1 + .../slack/hooks/useSlackConnectionStatus.ts | 16 +++++++- .../test/core/useKnockAuthState.test.ts | 37 ++++++++++++++++++ .../useMsTeamsConnectionStatus.test.tsx | 39 +++++++++++++++++++ .../slack/useSlackConnectionStatus.test.tsx | 39 +++++++++++++++++++ 15 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 .changeset/use-knock-auth-state-reactive-integrations.md create mode 100644 packages/react-core/src/modules/core/hooks/useKnockAuthState.ts create mode 100644 packages/react-core/test/core/useKnockAuthState.test.ts diff --git a/.changeset/use-knock-auth-state-reactive-integrations.md b/.changeset/use-knock-auth-state-reactive-integrations.md new file mode 100644 index 000000000..fcc45cb79 --- /dev/null +++ b/.changeset/use-knock-auth-state-reactive-integrations.md @@ -0,0 +1,12 @@ +--- +"@knocklabs/react-core": minor +"@knocklabs/react": minor +"@knocklabs/react-native": minor +"@knocklabs/expo": minor +--- + +Add `useKnockAuthState()` and make the Slack, MS Teams, and Expo integrations react to authentication changes. + +- New `useKnockAuthState(knock)` hook subscribes to a client's authentication state (`{ status, userId, userToken }`), re-rendering on login, logout, or a user switch. Backed by the subscribable `authStore` on `@knocklabs/client`. +- Slack and MS Teams connection status now reset and re-run `authCheck` when the authenticated user changes, instead of latching on the first check. The provider keys now include the userId so a user switch reliably re-renders consumers. Combined with the client-side guards, an unauthenticated user resolves to `disconnected` (never `error`) without a network request. +- Expo: `autoRegister` now waits for an authenticated user before registering a push token — deferring the OS permission prompt (no longer prompting logged-out users) and re-running registration once a user signs in. A notification tapped while logged out no longer fires a message-status update. diff --git a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx index 75c8df955..9b75d5d3c 100644 --- a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx +++ b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx @@ -1,5 +1,5 @@ import { Message, MessageEngagementStatus } from "@knocklabs/client"; -import { useKnockClient } from "@knocklabs/react-core"; +import { useKnockAuthState, useKnockClient } from "@knocklabs/react-core"; import { KnockPushNotificationProvider, usePushNotifications, @@ -67,6 +67,8 @@ const InternalExpoPushNotificationProvider: React.FC< autoRegister = true, }) => { const knockClient = useKnockClient(); + const { status: authStatus } = useKnockAuthState(knockClient); + const isAuthenticated = authStatus === "authenticated"; const { registerPushTokenToChannel, unregisterPushTokenFromChannel } = usePushNotifications(); @@ -150,6 +152,15 @@ const InternalExpoPushNotificationProvider: React.FC< return; } + // Skip when there's no authenticated user (e.g. a notification tapped + // after logout) — otherwise this fires a messages request with no user. + if (!knockClient.isAuthenticated()) { + knockClient.log( + "[Knock] Skipping status update; user is not authenticated", + ); + return; + } + return knockClient.messages.updateStatus(messageId, status); }, [knockClient], @@ -167,9 +178,12 @@ const InternalExpoPushNotificationProvider: React.FC< NotificationsModule.setNotificationHandler({ handleNotification }); }, [customNotificationHandler]); - // Auto-register for push notifications on mount if enabled + // Auto-register for push notifications once there is an authenticated user. + // 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. useEffect(() => { - if (!autoRegister) { + if (!autoRegister || !isAuthenticated) { return; } @@ -195,6 +209,7 @@ const InternalExpoPushNotificationProvider: React.FC< }; }, [ autoRegister, + isAuthenticated, knockExpoChannelId, registerForPushNotifications, registerPushTokenToChannel, diff --git a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx index 8ff4bec23..54309c814 100644 --- a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx +++ b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx @@ -81,6 +81,15 @@ const mockKnockClient = { 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. + useKnockAuthState: () => ({ + status: mockKnockClient.isAuthenticated() + ? "authenticated" + : "unauthenticated", + userId: mockKnockClient.isAuthenticated() ? "user_1" : undefined, + userToken: undefined, + }), })); describe("KnockExpoPushNotificationProvider", () => { @@ -158,6 +167,34 @@ describe("KnockExpoPushNotificationProvider", () => { expect(getByTestId("test-child")).toBeInTheDocument(); }); + test("defers auto-registration and the OS prompt while unauthenticated", async () => { + mockKnockClient.isAuthenticated.mockReturnValue(false); + mockRegisterPushTokenToChannel.mockClear(); + mockNotifications.getExpoPushTokenAsync.mockClear(); + + try { + const TestChild = () =>
Test Child
; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("test-child")).toBeInTheDocument(); + + // The auto-register effect must no-op: no channel registration and, + // crucially, no OS permission/token prompt for a logged-out user. + await waitFor(() => { + expect(getByTestId("test-child")).toBeInTheDocument(); + }); + expect(mockRegisterPushTokenToChannel).not.toHaveBeenCalled(); + expect(mockNotifications.getExpoPushTokenAsync).not.toHaveBeenCalled(); + } finally { + mockKnockClient.isAuthenticated.mockReturnValue(true); + } + }); + test("useExpoPushNotifications provides context values", () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts index 6994637ca..a7ca7a19b 100644 --- a/packages/react-core/src/index.ts +++ b/packages/react-core/src/index.ts @@ -18,6 +18,7 @@ export { useAuthenticatedKnockClient, useAuthPolling, useAuthPostMessageListener, + useKnockAuthState, useKnockClient, useStableOptions, } from "./modules/core"; diff --git a/packages/react-core/src/modules/core/hooks/index.ts b/packages/react-core/src/modules/core/hooks/index.ts index 4c2ce7d9b..28b3a3018 100644 --- a/packages/react-core/src/modules/core/hooks/index.ts +++ b/packages/react-core/src/modules/core/hooks/index.ts @@ -1,4 +1,5 @@ export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient"; export { default as useStableOptions } from "./useStableOptions"; +export { default as useKnockAuthState } from "./useKnockAuthState"; export { useAuthPostMessageListener } from "./useAuthPostMessageListener"; export { useAuthPolling } from "./useAuthPolling"; diff --git a/packages/react-core/src/modules/core/hooks/useKnockAuthState.ts b/packages/react-core/src/modules/core/hooks/useKnockAuthState.ts new file mode 100644 index 000000000..47d4cb58e --- /dev/null +++ b/packages/react-core/src/modules/core/hooks/useKnockAuthState.ts @@ -0,0 +1,21 @@ +import Knock, { KnockAuthState } from "@knocklabs/client"; +import { useStore } from "@tanstack/react-store"; + +/** + * Subscribes to a Knock client's authentication state, re-rendering when it + * changes — i.e. on login, logout, or a user switch. Backed by the subscribable + * `knock.authStore`, so it stays correct even when the client is + * re-authenticated in place or replaced (e.g. via the `enabled` prop on + * `KnockProvider`). + * + * @example + * ```ts + * const { status, userId } = useKnockAuthState(knock); + * const isAuthenticated = status === "authenticated"; + * ``` + */ +export function useKnockAuthState(knock: Knock): KnockAuthState { + return useStore(knock.authStore, (state) => state); +} + +export default useKnockAuthState; diff --git a/packages/react-core/src/modules/core/index.ts b/packages/react-core/src/modules/core/index.ts index 8d8d31868..b7e04a257 100644 --- a/packages/react-core/src/modules/core/index.ts +++ b/packages/react-core/src/modules/core/index.ts @@ -7,6 +7,7 @@ export { export { useAuthenticatedKnockClient, useStableOptions, + useKnockAuthState, useAuthPostMessageListener, useAuthPolling, } from "./hooks"; diff --git a/packages/react-core/src/modules/core/utils.ts b/packages/react-core/src/modules/core/utils.ts index c73567250..4c5eb9920 100644 --- a/packages/react-core/src/modules/core/utils.ts +++ b/packages/react-core/src/modules/core/utils.ts @@ -78,17 +78,19 @@ export function feedProviderKey( to trigger a re-render of the context when a key property changes. */ export function slackProviderKey({ + userId, knockSlackChannelId, tenantId, connectionStatus, errorLabel, }: { + userId?: Knock["userId"]; knockSlackChannelId: string; tenantId: string; connectionStatus: string; errorLabel: string | null; }) { - return [knockSlackChannelId, tenantId, connectionStatus, errorLabel] + return [userId, knockSlackChannelId, tenantId, connectionStatus, errorLabel] .filter((f) => f !== null && f !== undefined) .join("-"); } @@ -98,17 +100,19 @@ export function slackProviderKey({ to trigger a re-render of the context when a key property changes. */ export function msTeamsProviderKey({ + userId, knockMsTeamsChannelId, tenantId, connectionStatus, errorLabel, }: { + userId?: Knock["userId"]; knockMsTeamsChannelId: string; tenantId: string; connectionStatus: string; errorLabel: string | null; }) { - return [knockMsTeamsChannelId, tenantId, connectionStatus, errorLabel] + return [userId, knockMsTeamsChannelId, tenantId, connectionStatus, errorLabel] .filter((f) => f !== null && f !== undefined) .join("-"); } diff --git a/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx b/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx index 3c9e6e573..4f2855ac0 100644 --- a/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx +++ b/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx @@ -43,6 +43,7 @@ export const KnockMsTeamsProvider: React.FC< return ( ("connecting"); const [errorLabel, setErrorLabel] = useState(null); const [actionLabel, setActionLabel] = useState(null); + // When the authenticated user changes (login, logout, or switch), reset back + // to "connecting" so the effect below re-runs `authCheck` for the new user + // instead of leaving the previous user's latched status in place. + const previousUserIdRef = useRef(userId); + useEffect(() => { + if (previousUserIdRef.current !== userId) { + previousUserIdRef.current = userId; + setConnectionStatus("connecting"); + setErrorLabel(null); + } + }, [userId]); + useEffect(() => { const checkAuthStatus = async () => { if (connectionStatus !== "connecting") return; diff --git a/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx b/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx index 1d3152f98..d14e6f59c 100644 --- a/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx +++ b/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx @@ -58,6 +58,7 @@ export const KnockSlackProvider: React.FC< return ( ("connecting"); const [errorLabel, setErrorLabel] = useState(null); const [actionLabel, setActionLabel] = useState(null); + // When the authenticated user changes (login, logout, or switch), reset back + // to "connecting" so the effect below re-runs `authCheck` for the new user + // instead of leaving the previous user's latched status in place. + const previousUserIdRef = useRef(userId); + useEffect(() => { + if (previousUserIdRef.current !== userId) { + previousUserIdRef.current = userId; + setConnectionStatus("connecting"); + setErrorLabel(null); + } + }, [userId]); + useEffect(() => { const checkAuthStatus = async () => { if (connectionStatus !== "connecting") return; diff --git a/packages/react-core/test/core/useKnockAuthState.test.ts b/packages/react-core/test/core/useKnockAuthState.test.ts new file mode 100644 index 000000000..691a4a5c5 --- /dev/null +++ b/packages/react-core/test/core/useKnockAuthState.test.ts @@ -0,0 +1,37 @@ +import Knock from "@knocklabs/client"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useKnockAuthState } from "../../src"; + +describe("useKnockAuthState", () => { + it("returns the unauthenticated state before authentication", () => { + const knock = new Knock("pk_test_12345"); + + const { result } = renderHook(() => useKnockAuthState(knock)); + + expect(result.current.status).toBe("unauthenticated"); + expect(result.current.userId).toBeUndefined(); + }); + + it("re-renders with the authenticated state on login and clears it on logout", () => { + const knock = new Knock("pk_test_12345"); + + const { result } = renderHook(() => useKnockAuthState(knock)); + + act(() => { + knock.authenticate("user_123", "token_456"); + }); + + expect(result.current.status).toBe("authenticated"); + expect(result.current.userId).toBe("user_123"); + expect(result.current.userToken).toBe("token_456"); + + act(() => { + knock.logout(); + }); + + expect(result.current.status).toBe("unauthenticated"); + expect(result.current.userId).toBeUndefined(); + }); +}); diff --git a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx index 4b04dd00a..7075be816 100644 --- a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx +++ b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx @@ -12,6 +12,15 @@ const buildMockKnock = (authCheckImpl: () => Promise) => { 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,34 @@ 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") }); + + await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(2)); + expect(result.current.connectionStatus).toBe("connected"); + }); }); diff --git a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx index 18bbe958e..b82d7b8e7 100644 --- a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx +++ b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx @@ -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,34 @@ 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") }); + + await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(2)); + expect(result.current.connectionStatus).toBe("connected"); + }); }); From dc494f83aa92b24f01d72f324b56db1e6698a23d Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:26:36 -0500 Subject: [PATCH 04/14] docs: document the enabled prop and guide targeting semantics Add the readyToTarget x enabled/auth matrix to KnockGuideProvider's JSDoc, and document the enabled prop in the react, react-native, and expo READMEs plus the vanilla logout/quiescence story in the client README. --- packages/client/README.md | 20 +++++++++++++++++++ packages/expo/README.md | 17 ++++++++++++++++ .../guide/context/KnockGuideProvider.tsx | 20 +++++++++++++++++++ packages/react-native/README.md | 17 ++++++++++++++++ packages/react/README.md | 17 ++++++++++++++++ 5 files changed, 91 insertions(+) diff --git a/packages/client/README.md b/packages/client/README.md index da6d31573..88828ca4e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -43,6 +43,26 @@ knockClient.authenticate( ); ``` +### Logging out and authentication state + +While a client is unauthenticated it is fully quiescent: user-scoped calls (feed +fetches, mark-as-read, guides, Slack/Teams checks, etc.) become no-ops rather +than firing requests or throwing, and no real-time socket is opened. This makes +it safe to construct a client before you have a user. + +Call `logout()` to clear the current user and tear down all stateful +connections (the socket, the token-expiration timer, and the page-visibility +listener): + +```typescript +knockClient.logout(); +``` + +You can observe authentication state via `knockClient.authStatus` +(`"authenticated" | "unauthenticated"`) or subscribe to the `knockClient.authStore` +for changes. In React, prefer the `enabled` prop on `KnockProvider` (see +`@knocklabs/react`), which manages this lifecycle for you. + ### Retrieving new items from the feed ```typescript diff --git a/packages/expo/README.md b/packages/expo/README.md index 35ab2e44f..0c531e0b0 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -111,6 +111,23 @@ const YourAppLayout = () => { }; ``` +## Deferring activity with `enabled` + +`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Auto push-notification registration also waits for `enabled` to become `true`, so a logged-out user is never shown the OS permission prompt. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (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-core/src/modules/guide/context/KnockGuideProvider.tsx b/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx index eef983345..9861f4fc6 100644 --- a/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx +++ b/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx @@ -18,6 +18,26 @@ export const KnockGuideContext = React.createContext< export type KnockGuideProviderProps = { channelId: string; + /** + * Whether the targeting parameters for guide selection are loaded and ready. + * When `true`, the provider fetches guides and subscribes to real-time updates. + * + * This is independent of authentication. Effective guide activity requires + * **both** an authenticated user (i.e. a `KnockProvider` with a user and + * `enabled` not set to `false`) **and** `readyToTarget`: + * + * | `enabled` (auth) | `readyToTarget` | Guides fetch/subscribe? | + * | ---------------- | --------------- | ----------------------- | + * | `false` | `false` | No | + * | `false` | `true` | No (client no-ops) | + * | `true` | `false` | No | + * | `true` | `true` | Yes | + * + * `readyToTarget` means "my targeting params are loaded"; auth means "there is + * a user". When there is no user, the underlying guide client fetches/subscribes + * are safe no-ops (they neither throw nor hit the network), so it is fine to + * render `KnockGuideProvider` with `readyToTarget` while unauthenticated. + */ readyToTarget: boolean; listenForUpdates?: boolean; colorMode?: ColorMode; diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 41088ecb0..0858fffd8 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -55,6 +55,23 @@ const YourAppLayout = () => { }; ``` +## Deferring activity with `enabled` + +`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (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..4ee62f316 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -73,6 +73,23 @@ const YourAppLayout = () => { }; ``` +## Deferring activity with `enabled` + +`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (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: From 0b061c2564bab207a72a766a0400bcc5516af5e7 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 15:49:41 -0500 Subject: [PATCH 05/14] fix(react-core): simplify KnockProvider unmount teardown Replace the disposedRef/generation StrictMode self-heal with a plain effect cleanup that tears the client down on unmount. This removes the one-render window where a StrictMode simulated remount could return the previous (torn-down) memoized client before the generation bump rebuilt it. Addresses Cursor BugBot: "StrictMode serves torn-down client". --- .../core/hooks/useAuthenticatedKnockClient.ts | 34 +++---------------- 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts index 813bce159..eb6e7eb71 100644 --- a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts +++ b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts @@ -73,12 +73,6 @@ function useAuthenticatedKnockClient( ) { const knockRef = React.useRef(undefined); - // Tracks whether the current client was torn down by a prior effect cleanup - // (e.g. a React StrictMode simulated unmount) so we can rebuild it. - const disposedRef = React.useRef(false); - // Bumping this forces the memo below to build a fresh client after a teardown. - const [generation, setGeneration] = React.useState(0); - const { enabled = true, ...authenticateOptions } = options; const stableOptions = useStableOptions(authenticateOptions); @@ -141,34 +135,14 @@ function useAuthenticatedKnockClient( knockRef.current = knock; return knock; - // `generation` is included so a post-teardown revival (in the effect below) - // rebuilds the client; it is intentionally not read in the memo body. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - apiKey, - enabled, - stableUserIdOrObject, - userToken, - stableOptions, - generation, - ]); + }, [apiKey, enabled, stableUserIdOrObject, userToken, stableOptions]); // Tear the client down when the provider unmounts so we don't leak the socket, - // the token-expiration timer, or the page-visibility listener. Uses empty deps - // so it only fires on real unmount (transition teardown is handled in the memo - // above, and firing here on every `knock` change would double-tear-down). - // - // StrictMode double-invokes effects: the simulated unmount tears the client - // down, so on the simulated remount we rebuild a fresh one (mirrors the - // dispose/re-init pattern in `useNotifications`). + // the token-expiration timer, or the page-visibility listener. Transition + // teardown is handled in the memo above, so this uses empty deps to fire only + // on unmount. React.useEffect(() => { - if (disposedRef.current) { - disposedRef.current = false; - setGeneration((g) => g + 1); - } - return () => { - disposedRef.current = true; knockRef.current?.teardown(); }; }, []); From 8cab0feda169711775d745bbeeabaff736da8eff Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 15:49:50 -0500 Subject: [PATCH 06/14] test(react-core): fix race in Slack/Teams user-switch reset tests Wait for the connection status to resolve back to "connected" (which only happens after the second authCheck promise resolves) rather than asserting it synchronously right after waitFor(authCheck called twice), which was flaky under full-suite timing. --- .../test/ms-teams/useMsTeamsConnectionStatus.test.tsx | 8 ++++++-- .../test/slack/useSlackConnectionStatus.test.tsx | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx index 7075be816..75d4d783d 100644 --- a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx +++ b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx @@ -125,7 +125,11 @@ describe("useMsTeamsConnectionStatus", () => { // Switching users must reset the latched status and re-run authCheck. rerender({ knock: buildKnockWithUser("user_B") }); - await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(2)); - expect(result.current.connectionStatus).toBe("connected"); + // 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/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx index b82d7b8e7..80ce761c7 100644 --- a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx +++ b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx @@ -125,7 +125,11 @@ describe("useSlackConnectionStatus", () => { // Switching users must reset the latched status and re-run authCheck. rerender({ knock: buildKnockWithUser("user_B") }); - await waitFor(() => expect(authCheck).toHaveBeenCalledTimes(2)); - expect(result.current.connectionStatus).toBe("connected"); + // 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); }); }); From 70d1d946c5a68057f094cf682924e3f557e2bc43 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 08:33:15 -0500 Subject: [PATCH 07/14] fix(react-core): simplify KnockProvider unmount teardown Replace the generation/disposedRef StrictMode self-heal with a plain effect-cleanup teardown. Addresses a Cursor BugBot finding: the self-heal could briefly return a torn-down client on a StrictMode simulated remount. The simpler cleanup still fixes the real unmount leak (socket, token-refresh timer, page-visibility listener) without that path. --- ...knockprovider-enabled-client-quiescence.md | 15 ------------- .../core/hooks/useAuthenticatedKnockClient.ts | 21 +++++++++---------- .../core/useAuthenticatedKnockClient.test.ts | 2 +- 3 files changed, 11 insertions(+), 27 deletions(-) delete mode 100644 .changeset/knockprovider-enabled-client-quiescence.md diff --git a/.changeset/knockprovider-enabled-client-quiescence.md b/.changeset/knockprovider-enabled-client-quiescence.md deleted file mode 100644 index 05e831976..000000000 --- a/.changeset/knockprovider-enabled-client-quiescence.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@knocklabs/client": minor ---- - -Add a subscribable authentication-state signal and make the client fully quiescent while unauthenticated. This is the `@knocklabs/client` foundation for an upcoming `enabled` prop on `KnockProvider`. - -- `Knock` now exposes a subscribable `authStore` (a `@tanstack/store`) and an `authStatus` getter (`"authenticated" | "unauthenticated"`), updated on every `authenticate()` and `logout()`. -- New `Knock.logout()` clears credentials and tears down all stateful connections (feed channels, socket, token-expiration timer, and page-visibility listener), then lazily recreates the API client on next use. Re-authenticating after a logout rewires any surviving feed instances. -- Unauthenticated calls are now quiet no-ops instead of firing requests or throwing: - - Feed `markAs*` / `markAll*` / `fetchNextPage` skip the network **and** the optimistic store update. - - Guide `fetch()` / `subscribe()` and step `markAsSeen` / `markAsInteracted` / `markAsArchived` no longer throw (`fetch()` resolves to an error status). This fixes a crash when Guides render before a user is authenticated. - - Slack and MS Teams `authCheck` return a disconnected result, and `getChannels` / `getTeams` return empty results. - - `messages.batchUpdateStatuses` returns an empty array. -- Fix: the guide client now re-reads its socket from the API client on each `subscribe()`, so guide real-time keeps working after a re-authentication (previously it captured the socket once at construction and went stale on user/token changes). -- Fix: the guide client's `history.pushState`/`replaceState` monkey-patch is now shared and idempotent per window, so remounting a guide provider (e.g. toggling `enabled`) no longer nests patches or leaves the originals unrestored. diff --git a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts index eb6e7eb71..205cf1218 100644 --- a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts +++ b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts @@ -24,15 +24,14 @@ function authenticateWithOptions( export type AuthenticatedKnockClientOptions = KnockOptions & AuthenticateOptions & { /** - * When `false`, the Knock client is created but left **unauthenticated and - * fully quiescent** — no identify, no network requests, and no real-time - * socket activity — while children still render. Flipping it back to `true` - * authenticates and mounts everything, like a login; flipping it to `false` - * tears everything down, like a logout. + * When `false`, the Knock client is created but left idle: no identify call, + * no API requests, and no websocket, while children still render. Set it to + * `true` and it connects like a login; set it back to `false` and it + * disconnects like a logout. * - * The canonical use is to defer all activity until you have a complete - * identity — e.g. an enhanced-security user token that loads asynchronously - * (without this, a present `userId` with a not-yet-loaded token fires 401s): + * Use this to wait for a complete identity, for example a user token that + * loads asynchronously (without it, a present `userId` with a not-yet-loaded + * token fires 401s): * * ```tsx * useAuthenticatedKnockClient(apiKey, { id: userId }, userToken, { @@ -79,9 +78,9 @@ function useAuthenticatedKnockClient( const stableUserIdOrObject = useStableOptions(userIdOrUserWithProperties); const knock = React.useMemo(() => { - // When disabled, behave as if no user was provided: null the credentials so - // a fresh, unauthenticated client is created and stays fully quiescent. This - // makes `enabled: false → true` behave like a login (and the reverse like a + // When disabled, behave as if no user was provided: drop the credentials so + // a fresh, signed-out client is created and stays idle. This makes + // `enabled: false -> true` behave like a login (and the reverse like a // logout) through the same code path headless hook consumers already use. const activeUserIdOrObject = enabled ? stableUserIdOrObject : undefined; const activeUserToken = enabled ? userToken : undefined; diff --git a/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts b/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts index 7cea3dee4..b4bce10c2 100644 --- a/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts +++ b/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts @@ -296,7 +296,7 @@ describe("useAuthenticatedKnockClient", () => { }); describe("enabled option", () => { - it("creates an unauthenticated, quiescent client when enabled is false", () => { + it("creates an idle, signed-out client when enabled is false", () => { const { result } = renderHook( ({ apiKey, userId, userToken, options }) => useAuthenticatedKnockClient(apiKey, userId, userToken, options), From 771c4f1939c4890ba10bd3e24ceaa5e36f9675e5 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 08:33:28 -0500 Subject: [PATCH 08/14] docs: use plainer wording for the enabled prop and changelog Rewrite the changesets, READMEs, and doc comments in plain language, dropping "quiescent"/"quiescence" and similar jargon, so the enabled-prop behavior is easy to read. --- .changeset/knockprovider-enabled-client.md | 13 +++++++++++++ .changeset/knockprovider-enabled-prop.md | 17 ++++++----------- ...se-knock-auth-state-reactive-integrations.md | 8 ++++---- packages/client/README.md | 8 ++++---- packages/client/src/clients/feed/feed.ts | 10 +++++----- packages/client/src/interfaces.ts | 6 +++--- packages/client/src/knock.ts | 8 ++++---- packages/client/test/clients/feed/feed.test.ts | 2 +- .../client/test/clients/guide/guide.test.ts | 2 +- .../test/clients/messages/messages.test.ts | 2 +- .../test/clients/ms-teams/ms-teams.test.ts | 2 +- .../client/test/clients/slack/slack.test.ts | 2 +- packages/expo/README.md | 2 +- .../src/modules/core/context/KnockProvider.tsx | 13 ++++++------- packages/react-native/README.md | 2 +- packages/react/README.md | 2 +- 16 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 .changeset/knockprovider-enabled-client.md diff --git a/.changeset/knockprovider-enabled-client.md b/.changeset/knockprovider-enabled-client.md new file mode 100644 index 000000000..7564d56e0 --- /dev/null +++ b/.changeset/knockprovider-enabled-client.md @@ -0,0 +1,13 @@ +--- +"@knocklabs/client": minor +--- + +Make the client do nothing (instead of throwing or making requests) when there's no signed-in user, and add tools to manage sign-in state. + +- New `Knock.logout()` clears the user and disconnects everything: the websocket, the token-refresh timer, and the page-visibility listener. +- New `knock.authStatus` (`"authenticated"` or `"unauthenticated"`) and a subscribable `knock.authStore` to check or react to whether a user is signed in. +- With no signed-in user, these now do nothing instead of throwing or calling the API: + - Feed `markAs*` / `markAll*` / `fetchNextPage` (they also skip the optimistic UI update). + - Guides `fetch` / `subscribe` and the step actions. These previously threw, which could crash the app when Guides rendered before a user was set. + - Slack/MS Teams `authCheck` (returns "not connected"), `getChannels` / `getTeams` (return empty), and `messages.batchUpdateStatuses` (returns `[]`). +- Fixes two Guide bugs: real-time updates broke after a re-login (a stale socket reference), and the `history` patch used for location tracking broke when a Guide provider remounted. diff --git a/.changeset/knockprovider-enabled-prop.md b/.changeset/knockprovider-enabled-prop.md index c07d8152a..1744626ae 100644 --- a/.changeset/knockprovider-enabled-prop.md +++ b/.changeset/knockprovider-enabled-prop.md @@ -7,20 +7,15 @@ Add an `enabled` prop to `KnockProvider` (and an `enabled` option to `useAuthenticatedKnockClient`). -When `enabled` is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no identify, no network requests, and no real-time socket activity. Flipping it to `true` authenticates and mounts everything (like a login); flipping it back to `false` tears everything down and clears the client's stores (like a logout). It defaults to `true`, so existing usage is unchanged. +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. -This is the recommended replacement for conditionally mounting `KnockProvider`, and the canonical way to defer activity until you have a complete identity — e.g. an enhanced-security user token that loads asynchronously: +Use this instead of conditionally mounting `KnockProvider`, for example to wait for a user token that loads asynchronously: ```tsx - + ``` -Also fixed while here: +Also fixed: -- `useFeedSettings` no longer fires `GET /v1/users/undefined/feeds/.../settings` for an unauthenticated user. -- `KnockProvider` now tears down its Knock client (socket, token-expiration timer, and page-visibility listener) on unmount instead of leaking it. +- `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. diff --git a/.changeset/use-knock-auth-state-reactive-integrations.md b/.changeset/use-knock-auth-state-reactive-integrations.md index fcc45cb79..b77910300 100644 --- a/.changeset/use-knock-auth-state-reactive-integrations.md +++ b/.changeset/use-knock-auth-state-reactive-integrations.md @@ -5,8 +5,8 @@ "@knocklabs/expo": minor --- -Add `useKnockAuthState()` and make the Slack, MS Teams, and Expo integrations react to authentication changes. +Add `useKnockAuthState()` and make Slack, MS Teams, and Expo respond to sign-in changes. -- New `useKnockAuthState(knock)` hook subscribes to a client's authentication state (`{ status, userId, userToken }`), re-rendering on login, logout, or a user switch. Backed by the subscribable `authStore` on `@knocklabs/client`. -- Slack and MS Teams connection status now reset and re-run `authCheck` when the authenticated user changes, instead of latching on the first check. The provider keys now include the userId so a user switch reliably re-renders consumers. Combined with the client-side guards, an unauthenticated user resolves to `disconnected` (never `error`) without a network request. -- Expo: `autoRegister` now waits for an authenticated user before registering a push token — deferring the OS permission prompt (no longer prompting logged-out users) and re-running registration once a user signs in. A notification tapped while logged out no longer fires a message-status update. +- 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. diff --git a/packages/client/README.md b/packages/client/README.md index 88828ca4e..8cb39eb4c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -45,10 +45,10 @@ knockClient.authenticate( ### Logging out and authentication state -While a client is unauthenticated it is fully quiescent: user-scoped calls (feed -fetches, mark-as-read, guides, Slack/Teams checks, etc.) become no-ops rather -than firing requests or throwing, and no real-time socket is opened. This makes -it safe to construct a client before you have a user. +While no user is signed in, the client stays idle: user-scoped calls (feed +fetches, mark-as-read, Guides, Slack/Teams checks, and so on) do nothing instead +of throwing or making requests, and no websocket is opened. This makes it safe +to construct a client before you have a user. Call `logout()` to clear the current user and tear down all stateful connections (the socket, the token-expiration timer, and the page-visibility diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 02dc3ca64..139e15931 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -165,11 +165,11 @@ class Feed { } /** - * Returns `true` when the current user is authenticated. When not, logs and - * returns `false` so mutation methods can no-op early — without touching the - * store (no optimistic update) or the network. This upholds the SDK-wide - * "unauthenticated ⇒ quiescent" invariant for auto-fired paths such as the - * popover's `markAllAsSeen` on open or a cell's `markAsInteracted` on click. + * Returns `true` when a user is signed in. When not, logs and returns `false` + * so mutation methods can skip early, without touching the store (no + * optimistic update) or the network. This matters for actions that fire + * automatically, like the popover's `markAllAsSeen` on open or a cell's + * `markAsInteracted` on click. */ private canMutate(operation: string): boolean { if (this.knock.isAuthenticated()) { diff --git a/packages/client/src/interfaces.ts b/packages/client/src/interfaces.ts index d12350cc1..3f1b42f42 100644 --- a/packages/client/src/interfaces.ts +++ b/packages/client/src/interfaces.ts @@ -61,9 +61,9 @@ export interface AuthenticateOptions { } /** - * Whether a `Knock` instance currently has a user identity to act on behalf of. - * When `unauthenticated`, the instance is fully quiescent: no network requests - * and no real-time socket activity occur until `authenticate` is called. + * Whether a `Knock` instance currently has a signed-in user. When + * `unauthenticated`, the instance stays idle (no requests, no websocket) until + * `authenticate` is called. */ export type KnockAuthStatus = "authenticated" | "unauthenticated"; diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index 3f1ba3dd9..4de759cd7 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -198,10 +198,10 @@ class Knock { * (real-time feed channels, the socket, the token-expiration timer, and the * page-visibility listener). * - * After `logout()` the instance is fully quiescent — no network or socket - * activity occurs until `authenticate()` is called again. The API client is - * dropped so that a fresh one is lazily constructed on next use rather than - * holding an open socket + document listener while logged out. + * After `logout()` the instance stays idle (no requests, no websocket) until + * `authenticate()` is called again. The API client is dropped so a fresh one + * is built on next use, rather than holding an open socket and document + * listener while logged out. */ logout() { this.log("Logging out and tearing down connections"); diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index 0f7fa85a3..fbe6249b3 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1495,7 +1495,7 @@ describe("Feed", () => { }); }); - describe("Unauthenticated mutations are quiescent", () => { + describe("Does nothing when unauthenticated", () => { const getUnauthenticatedSetup = () => { const { knock, mockApiClient } = createMockKnock(); // not authenticated const feed = new Feed( diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 2fec52bcf..dd8554840 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -596,7 +596,7 @@ describe("KnockGuideClient", () => { }); }); - describe("Unauthenticated quiescence", () => { + describe("Does nothing when unauthenticated", () => { const unauthGuide = { __typename: "Guide", channel_id: channelId, diff --git a/packages/client/test/clients/messages/messages.test.ts b/packages/client/test/clients/messages/messages.test.ts index c47c166c3..ee6752318 100644 --- a/packages/client/test/clients/messages/messages.test.ts +++ b/packages/client/test/clients/messages/messages.test.ts @@ -748,7 +748,7 @@ describe("MessageClient", () => { }); }); - describe("Unauthenticated quiescence", () => { + describe("Does nothing when unauthenticated", () => { test("batchUpdateStatuses returns an empty array without calling the API", async () => { const { knock, mockApiClient } = createMockKnock(); // not authenticated diff --git a/packages/client/test/clients/ms-teams/ms-teams.test.ts b/packages/client/test/clients/ms-teams/ms-teams.test.ts index 54eb23c63..3475fa897 100644 --- a/packages/client/test/clients/ms-teams/ms-teams.test.ts +++ b/packages/client/test/clients/ms-teams/ms-teams.test.ts @@ -631,7 +631,7 @@ describe("Microsoft Teams Client", () => { }); }); - describe("Unauthenticated quiescence", () => { + describe("Does nothing when unauthenticated", () => { test("authCheck returns a disconnected shape without calling the API", async () => { const { knock, mockApiClient } = createMockKnock(); // not authenticated const client = new MsTeamsClient(knock); diff --git a/packages/client/test/clients/slack/slack.test.ts b/packages/client/test/clients/slack/slack.test.ts index cb1fd8310..a833d81ff 100644 --- a/packages/client/test/clients/slack/slack.test.ts +++ b/packages/client/test/clients/slack/slack.test.ts @@ -511,7 +511,7 @@ describe("Slack Client", () => { }); }); - describe("Unauthenticated quiescence", () => { + describe("Does nothing when unauthenticated", () => { test("authCheck returns a disconnected shape without calling the API", async () => { const { knock, mockApiClient } = createMockKnock(); // not authenticated const client = new SlackClient(knock); diff --git a/packages/expo/README.md b/packages/expo/README.md index 0c531e0b0..ddda17072 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -113,7 +113,7 @@ const YourAppLayout = () => { ## Deferring activity with `enabled` -`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Auto push-notification registration also waits for `enabled` to become `true`, so a logged-out user is never shown the OS permission prompt. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (like a logout). +`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. Auto push-notification registration also waits for `enabled` to become `true`, so logged-out users never see the OS permission prompt. 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`: diff --git a/packages/react-core/src/modules/core/context/KnockProvider.tsx b/packages/react-core/src/modules/core/context/KnockProvider.tsx index 915f80cbe..2ecce95d5 100644 --- a/packages/react-core/src/modules/core/context/KnockProvider.tsx +++ b/packages/react-core/src/modules/core/context/KnockProvider.tsx @@ -27,14 +27,13 @@ export type KnockProviderProps = { logLevel?: LogLevel; branch?: string; /** - * When `false`, render children but keep the Knock client unauthenticated and - * fully quiescent — no identify, network, or socket activity. Flipping it to - * `true` authenticates and mounts everything (like a login); flipping it back - * to `false` tears everything down (like a logout). Defaults to `true`. + * When `false`, render children but keep the Knock client idle: no identify + * call, no API requests, and no websocket. Set it to `true` and it connects + * like a login; set it back to `false` and it disconnects like a logout. + * Defaults to `true`. * - * This is the recommended way to gate the provider on a complete identity — - * e.g. an enhanced-security token that loads asynchronously — instead of - * conditionally mounting `KnockProvider`: + * Use this to wait for a complete identity (for example, a user token that + * loads asynchronously) instead of conditionally mounting `KnockProvider`: * * ```tsx * { ## Deferring activity with `enabled` -`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (like a logout). +`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`: diff --git a/packages/react/README.md b/packages/react/README.md index 4ee62f316..3da6e21a4 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -75,7 +75,7 @@ const YourAppLayout = () => { ## Deferring activity with `enabled` -`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (like a logout). +`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`: From 33791913b80c4804bf9f1dff3dc1e218ba7cbdd2 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 08:44:05 -0500 Subject: [PATCH 09/14] test: cover the enabled-prop patch lines flagged by Codecov Add a KnockSlackProvider render test, an Expo test that fires a notification while unauthenticated (hitting the updateMessageStatus guard), and a guide test that calls the patched history.pushState to exercise the shared location-change patch. --- .../client/test/clients/guide/guide.test.ts | 25 ++++++++++ ...KnockExpoPushNotificationProvider.test.tsx | 30 ++++++++++++ .../test/slack/KnockSlackProvider.test.tsx | 48 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 packages/react-core/test/slack/KnockSlackProvider.test.tsx diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index dd8554840..c03eae834 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -3867,6 +3867,31 @@ describe("KnockGuideClient", () => { expect(windowWithHistory.history.pushState).toBe(originalPushState); expect(windowWithHistory.history.replaceState).toBe(originalReplaceState); }); + + test("fires a location change when the patched history.pushState is called", () => { + const originalPushState = vi.fn(); + vi.stubGlobal("window", { + ...mockWindow, + location: { href: "https://example.com/next" }, + history: { pushState: originalPushState, replaceState: vi.fn() }, + }); + + const client = new KnockGuideClient( + mockKnock, + channelId, + {}, + { trackLocationFromWindow: true }, + ); + const setLocationSpy = vi.spyOn(client, "setLocation"); + + // Calling the patched pushState runs the original and schedules a check. + window.history.pushState(null, "", "/next"); + expect(originalPushState).toHaveBeenCalled(); + + // The location check runs on the next tick. + vi.advanceTimersByTime(1); + expect(setLocationSpy).toHaveBeenCalledWith("https://example.com/next"); + }); }); describe("private methods", () => { diff --git a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx index 54309c814..c31b8d5c6 100644 --- a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx +++ b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx @@ -195,6 +195,36 @@ describe("KnockExpoPushNotificationProvider", () => { } }); + test("skips the message-status update for a notification received while unauthenticated", () => { + mockKnockClient.isAuthenticated.mockReturnValue(false); + mockKnockClient.messages.updateStatus.mockClear(); + mockNotifications.addNotificationReceivedListener.mockClear(); + + try { + const TestChild = () =>
Test Child
; + + render( + + + , + ); + + // Fire the received-notification handler the provider registered, with a + // Knock notification (it has a knock_message_id). + const receivedHandler = + mockNotifications.addNotificationReceivedListener.mock.calls.at(-1)?.[0]; + expect(receivedHandler).toBeDefined(); + receivedHandler({ + request: { content: { data: { knock_message_id: "msg_1" } } }, + }); + + // The status update is skipped because no user is signed in. + expect(mockKnockClient.messages.updateStatus).not.toHaveBeenCalled(); + } finally { + mockKnockClient.isAuthenticated.mockReturnValue(true); + } + }); + test("useExpoPushNotifications provides context values", () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( 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", + ); + }); +}); From d5e0dd6e64cbbdf0fc8989c9b8fbd83c52e1a9c1 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 09:58:36 -0500 Subject: [PATCH 10/14] fix(client): stop an in-flight token refresh from resurrecting a logged-out instance The onUserTokenExpiring callback re-authenticated after its await with no check that logout()/teardown() had run in the meantime, which rebuilt the API client and rescheduled the timer. Null the timer on teardown and bail in the callback if the timer id is no longer current. --- packages/client/src/knock.ts | 14 ++++++++++- packages/client/test/knock.test.ts | 38 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index 4de759cd7..32bcc7dcc 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -230,6 +230,9 @@ class Knock { teardown() { if (this.tokenExpirationTimer) { clearTimeout(this.tokenExpirationTimer); + // Clear the reference so an in-flight refresh callback can detect that it + // was torn down and skip re-authenticating (see maybeScheduleUserTokenExpiration). + this.tokenExpirationTimer = null; } this.apiClient?.teardown(); } @@ -292,9 +295,17 @@ class Knock { // ^ now ^ expiration offset ^ expires at const msInFuture = expiresAtMs - timeBeforeExpirationInMs - nowMs; - this.tokenExpirationTimer = setTimeout(async () => { + const timerId = setTimeout(async () => { const newToken = await callbackFn(this.userToken as string, decoded); + // If we were torn down (logout/unmount) or re-authenticated while the + // callback was awaiting, the timer reference will have changed (or been + // cleared). Bail so we don't resurrect a logged-out instance by + // re-authenticating and re-opening connections. + if (this.tokenExpirationTimer !== timerId) { + return; + } + // Reauthenticate which will handle reinitializing sockets if (typeof newToken === "string") { this.authenticate(this.userId!, newToken, { @@ -303,6 +314,7 @@ class Knock { }); } }, msInFuture); + this.tokenExpirationTimer = timerId; } } diff --git a/packages/client/test/knock.test.ts b/packages/client/test/knock.test.ts index be5f9b18a..4858ccec8 100644 --- a/packages/client/test/knock.test.ts +++ b/packages/client/test/knock.test.ts @@ -625,6 +625,44 @@ describe("Knock Client", () => { expect(onUserTokenExpiring).not.toHaveBeenCalled(); }); + + test("does not re-authenticate from an in-flight token refresh after logout", async () => { + const knock = new Knock("pk_test_12345"); + const authenticateSpy = vi.spyOn(knock, "authenticate"); + + const futureExp = Math.floor((Date.now() + 60000) / 1000); + vi.mocked(jwtDecode).mockReturnValueOnce({ exp: futureExp }); + + // A refresh callback we can hold open while the user logs out. + let resolveRefresh!: (token: string) => void; + const onUserTokenExpiring = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + knock.authenticate("user_123", "token_abc", { + onUserTokenExpiring, + timeBeforeExpirationInMs: 10000, + }); + authenticateSpy.mockClear(); + + // Fire the refresh timer; the callback is now awaiting. + await vi.advanceTimersByTimeAsync(50000); + expect(onUserTokenExpiring).toHaveBeenCalled(); + + // The user logs out while the refresh is still in flight. + knock.logout(); + + // The refresh resolves with a fresh token. + resolveRefresh("token_new"); + await vi.runAllTimersAsync(); + + // It must NOT resurrect the logged-out instance. + expect(authenticateSpy).not.toHaveBeenCalled(); + expect(knock.isAuthenticated()).toBe(false); + }); }); describe("Authentication with reinitialize", () => { From f0b9de68a4be747a707a1f4d198a525264305ad6 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 09:58:36 -0500 Subject: [PATCH 11/14] fix(react): export useKnockAuthState from @knocklabs/react The react package curates its re-exports by name and this one was missing, so it was unimportable despite being advertised. Guard it in the barrel test. --- packages/react/src/index.ts | 1 + packages/react/test/barrels/publicApi.test.ts | 4 ++++ 2 files changed, 5 insertions(+) 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", () => { From bf0a993f884c290133301d96f2430db6cb612fb2 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 09:58:36 -0500 Subject: [PATCH 12/14] fix(react-core): drop a stale Slack/Teams authCheck on user switch If a user switch happened while the first authCheck was still in flight, the effect never re-ran (status was still "connecting") and the previous users result latched. Depend on userId and ignore superseded results. --- .../hooks/useMsTeamsConnectionStatus.ts | 20 +++++- .../slack/hooks/useSlackConnectionStatus.ts | 13 +++- .../slack/useSlackConnectionStatus.test.tsx | 62 ++++++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts b/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts index ae44daf13..fcdaa1a3c 100644 --- a/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts +++ b/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts @@ -36,10 +36,13 @@ function useMsTeamsConnectionStatus( previousUserIdRef.current = userId; setConnectionStatus("connecting"); setErrorLabel(null); + setActionLabel(null); } }, [userId]); useEffect(() => { + let ignore = false; + const checkAuthStatus = async () => { if (connectionStatus !== "connecting") return; @@ -49,6 +52,10 @@ function useMsTeamsConnectionStatus( knockChannelId: knockMsTeamsChannelId, }); + // A newer user/tenant/instance superseded this check while it was in + // flight; drop its result so we don't latch the previous user's status. + if (ignore) return; + if (authRes.connection?.ok === true) { return setConnectionStatus("connected"); } @@ -73,12 +80,23 @@ function useMsTeamsConnectionStatus( // This is for any Knock errors that would require a reconnect. setConnectionStatus("error"); } catch (_error) { + if (ignore) return; setConnectionStatus("error"); } }; checkAuthStatus(); - }, [connectionStatus, tenantId, knockMsTeamsChannelId, knock.msTeams, t]); + return () => { + ignore = true; + }; + }, [ + connectionStatus, + tenantId, + knockMsTeamsChannelId, + knock.msTeams, + t, + userId, + ]); return { connectionStatus, diff --git a/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts b/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts index 87f6d297e..5f9ad7850 100644 --- a/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts +++ b/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts @@ -47,10 +47,13 @@ function useSlackConnectionStatus( previousUserIdRef.current = userId; setConnectionStatus("connecting"); setErrorLabel(null); + setActionLabel(null); } }, [userId]); useEffect(() => { + let ignore = false; + const checkAuthStatus = async () => { if (connectionStatus !== "connecting") return; @@ -60,6 +63,10 @@ function useSlackConnectionStatus( knockChannelId: knockSlackChannelId, }); + // A newer user/tenant/instance superseded this check while it was in + // flight; drop its result so we don't latch the previous user's status. + if (ignore) return; + if (authRes.connection?.ok) { return setConnectionStatus("connected"); } @@ -91,12 +98,16 @@ function useSlackConnectionStatus( setConnectionStatus("error"); } catch (_error) { + if (ignore) return; setConnectionStatus("error"); } }; checkAuthStatus(); - }, [connectionStatus, tenantId, knockSlackChannelId, knock.slack, t]); + return () => { + ignore = true; + }; + }, [connectionStatus, tenantId, knockSlackChannelId, knock.slack, t, userId]); return { connectionStatus, diff --git a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx index 80ce761c7..8008b8931 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"; @@ -132,4 +132,64 @@ describe("useSlackConnectionStatus", () => { ); 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"); + }); }); From 0e961931de11ba6e22c3ebf3e46c794640e22a31 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 09:58:36 -0500 Subject: [PATCH 13/14] test(react-core): assert the feed subtree remounts when enabled flips true Uses a real Knock (not the singleton-mocked one) so the fresh-instance + feedProviderKey remount that drives the feed to refetch is actually observed. --- .../test/core/enabledFeedReload.test.tsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/react-core/test/core/enabledFeedReload.test.tsx diff --git a/packages/react-core/test/core/enabledFeedReload.test.tsx b/packages/react-core/test/core/enabledFeedReload.test.tsx new file mode 100644 index 000000000..ca8160dd2 --- /dev/null +++ b/packages/react-core/test/core/enabledFeedReload.test.tsx @@ -0,0 +1,92 @@ +import { render, waitFor } from "@testing-library/react"; +import { useEffect } from "react"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { KnockFeedProvider, KnockProvider, useKnockClient } from "../../src"; + +// The feed provider opens a phoenix socket once authenticated; jsdom has no +// WebSocket/BroadcastChannel, so provide inert stubs. +class MockWebSocket { + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: (() => void) | null = null; + onerror: (() => void) | null = null; + binaryType = ""; + constructor(public url: string) {} + send() {} + close() {} +} +class MockBroadcastChannel { + onmessage: (() => void) | null = null; + constructor(public name: string) {} + postMessage() {} + close() {} + addEventListener() {} + removeEventListener() {} +} + +beforeAll(() => { + vi.stubGlobal("WebSocket", MockWebSocket); + vi.stubGlobal("BroadcastChannel", MockBroadcastChannel); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const FEED_ID = "01234567-89ab-cdef-0123-456789abcdef"; + +describe("KnockProvider enabled -> feed reload", () => { + test("builds a fresh authenticated client and remounts the feed subtree when enabled flips true", async () => { + let feedMountCount = 0; + const seenKnocks: unknown[] = []; + + // Counts how many times the feed subtree mounts. A remount (not just a + // re-render) is the observable outcome that drives the feed to refetch. + const FeedChild = () => { + useEffect(() => { + feedMountCount += 1; + }, []); + return null; + }; + const CaptureKnock = () => { + seenKnocks.push(useKnockClient()); + return null; + }; + + const tree = (enabled: boolean) => ( + + + + + + + ); + + const { rerender } = render(tree(false)); + + // Disabled: the client is unauthenticated and the feed mounted exactly once. + const disabledKnock = seenKnocks.at(-1) as { + isAuthenticated: () => boolean; + }; + expect(disabledKnock.isAuthenticated()).toBe(false); + await waitFor(() => expect(feedMountCount).toBe(1)); + + // Enable -> a brand-new authenticated client, and the feed subtree remounts + // (its `feedProviderKey` now includes the userId), which is what makes it + // refetch on login. + rerender(tree(true)); + + await waitFor(() => expect(feedMountCount).toBe(2)); + const enabledKnock = seenKnocks.at(-1) as { + isAuthenticated: () => boolean; + }; + expect(enabledKnock).not.toBe(disabledKnock); + expect(enabledKnock.isAuthenticated()).toBe(true); + }); +}); From 431a5eb350c3fe31d7b4153492cf3670d6277b13 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Fri, 10 Jul 2026 10:58:08 -0500 Subject: [PATCH 14/14] test(react-core): cover the ignore-on-reject path for a superseded Slack authCheck --- .../slack/useSlackConnectionStatus.test.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx index 8008b8931..945af4573 100644 --- a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx +++ b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx @@ -192,4 +192,68 @@ describe("useSlackConnectionStatus", () => { }); 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"); + }); });