Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/knockprovider-enabled-client-quiescence.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions .changeset/knockprovider-enabled-prop.md
Original file line number Diff line number Diff line change
@@ -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
<KnockProvider
apiKey={apiKey}
user={{ id: userId }}
userToken={userToken}
enabled={Boolean(userId && userToken)}
>
```

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.
12 changes: 12 additions & 0 deletions .changeset/use-knock-auth-state-reactive-integrations.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions packages/client/src/clients/feed/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -219,6 +239,8 @@ class Feed {
}

async markAsUnseen(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsUnseen")) return;

this.optimisticallyPerformStatusUpdate(
itemOrItems,
"unseen",
Expand All @@ -230,6 +252,8 @@ class Feed {
}

async markAsRead(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsRead")) return;

const now = new Date().toISOString();
this.optimisticallyPerformStatusUpdate(
itemOrItems,
Expand All @@ -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
Expand Down Expand Up @@ -284,6 +310,8 @@ class Feed {
}

async markAsUnread(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsUnread")) return;

this.optimisticallyPerformStatusUpdate(
itemOrItems,
"unread",
Expand All @@ -298,6 +326,8 @@ class Feed {
itemOrItems: FeedItemOrItems,
metadata?: Record<string, string>,
) {
if (!this.canMutate("markAsInteracted")) return;

const now = new Date().toISOString();
this.optimisticallyPerformStatusUpdate(
itemOrItems,
Expand All @@ -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 =
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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();

Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/clients/feed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading