From 493658a5439fb786842543e419f990f5c00e611d Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:25:07 -0500 Subject: [PATCH 1/6] 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 d9070cdeb1bac10e4babdfcf172a193419511aad Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:25:33 -0500 Subject: [PATCH 2/6] 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 3e6c97319a534fa8c13d8227d707ccff215bde31 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:26:16 -0500 Subject: [PATCH 3/6] 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 b9854aa5c30d8044ea6c9e602203469eb1bb2a48 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 14:26:36 -0500 Subject: [PATCH 4/6] 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 bac98470f2e4e583cfe7e4ab10b487ac127429f8 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 15:49:41 -0500 Subject: [PATCH 5/6] 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 77d1f6cee7fd82efd8b02473ea59800b5208e420 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 9 Jul 2026 15:49:50 -0500 Subject: [PATCH 6/6] 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); }); });