Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/chatty-cars-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@knocklabs/client": minor
---

Exclude metadata when refetching feed after new message received

Starting with this release, if you configure a feed client to listen for events via [`Feed.on()`](https://docs.knock.app/in-app-ui/javascript/sdk/feed-client#on), the payload for feed events of type `"items.received.realtime"` will always have `metadata` set to `undefined`.

```js
const knockFeed = knock.feeds.initialize(process.env.KNOCK_FEED_CHANNEL_ID);

knockFeed.on("items.received.realtime", (eventPayload) => {
// eventPayload.metadata will always be undefined here
const { items, metadata } = eventPayload;
});
```
40 changes: 30 additions & 10 deletions packages/client/src/clients/feed/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ import {
FeedMessagesReceivedPayload,
FeedRealTimeCallback,
} from "./types";
import { getFormattedTriggerData, mergeDateRangeParams } from "./utils";
import {
getFormattedExclude,
getFormattedTriggerData,
mergeAndDedupeArrays,
mergeDateRangeParams,
} from "./utils";

// Default options to apply
const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
Expand Down Expand Up @@ -536,19 +541,27 @@ class Feed {
// Set the loading type based on the request type it is
state.setNetworkStatus(options.__loadingType ?? NetworkStatus.loading);

const mergedOptions = {
...this.defaultOptions,
...mergeDateRangeParams(options),
exclude: mergeAndDedupeArrays(
this.defaultOptions.exclude,
options.exclude,
),
};

// trigger_data should be a JSON string for the API
// this function will format the trigger data if it's an object
// https://docs.knock.app/reference#get-feed
const formattedTriggerData = getFormattedTriggerData({
...this.defaultOptions,
...options,
});
// https://docs.knock.app/api-reference/users/feeds/list_items
const formattedTriggerData = getFormattedTriggerData(mergedOptions);

const formattedExclude = getFormattedExclude(mergedOptions);

// Always include the default params, if they have been set
const queryParams: FetchFeedOptionsForRequest = {
...this.defaultOptions,
...mergeDateRangeParams(options),
...mergedOptions,
trigger_data: formattedTriggerData,
exclude: formattedExclude,
// Unset options that should not be sent to the API
__loadingType: undefined,
__fetchSource: undefined,
Expand Down Expand Up @@ -599,7 +612,8 @@ class Feed {

const eventPayload = {
items: response.entries as FeedItem[],
metadata: response.meta as FeedMetadata,
// meta will not be present on the response when __fetchSource is "socket"
metadata: response.meta as FeedMetadata | undefined,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FeedResponse.meta type not updated to reflect optional

Medium Severity

The FeedResponse interface still defines meta: FeedMetadata as required, but after this change, meta can be undefined at runtime when exclude includes "meta" (which now happens automatically for socket-triggered fetches). The cast to FeedMetadata | undefined and the meta ?? state.metadata fallback are runtime workarounds, but the FeedResponse type itself was not updated. Since FeedResponse is a public export used by FeedRealTimeCallback, consumers of the legacy "messages.new" event who access resp.meta.total_count (or similar) will encounter a runtime crash when the fetch is socket-triggered — and TypeScript won't warn them because the type says meta is always present.

Additional Locations (1)

Fix in Cursor Fix in Web

event: feedEventType,
};

Expand Down Expand Up @@ -650,7 +664,13 @@ class Feed {
}

// Fetch the items before the current head (if it exists)
this.fetch({ before: currentHead?.__cursor, __fetchSource: "socket" });
this.fetch({
before: currentHead?.__cursor,
__fetchSource: "socket",
// The socket event payload should *always* include metadata,
// but to be safe, only exclude meta from the fetch when it's present
exclude: metadata ? ["meta"] : [],
});
}

private buildUserFeedId() {
Expand Down
20 changes: 17 additions & 3 deletions packages/client/src/clients/feed/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,35 @@ export interface FeedClientOptions {
* @default "compact"
*/
mode?: "rich" | "compact";
/**
* Field paths to exclude from the response. Use dot notation for nested fields
* (e.g., "entries.archived_at"). Limited to 3 levels deep.
*/
exclude?: string[];
}

export type FetchFeedOptions = {
__loadingType?: NetworkStatus.loading | NetworkStatus.fetchMore;
__fetchSource?: "socket" | "http";
} & Omit<FeedClientOptions, "__experimentalCrossBrowserUpdates">;

// The final data shape that is sent to the API
// Should match types here: https://docs.knock.app/reference#get-feed
/**
* The final data shape that is sent to the the list feed items endpoint of the Knock API.
*
* @see https://docs.knock.app/api-reference/users/feeds/list_items
*/
export type FetchFeedOptionsForRequest = Omit<
FeedClientOptions,
"trigger_data"
"trigger_data" | "exclude"
> & {
// Formatted trigger data into a string
trigger_data?: string;
/** Fields to exclude from the response, joined by commas. */
exclude?: string;
"inserted_at.gte"?: string;
"inserted_at.lte"?: string;
"inserted_at.gt"?: string;
"inserted_at.lt"?: string;
Comment on lines +86 to +89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we mean to add these?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it’s a mistake that they weren’t already in the FetchFeedOptionsForRequest type.

FetchFeedOptionsForRequest is meant to represent the params included in the API request. Notice that the mergeDateRangeParams function transforms the inserted_at_date_range option on FeedClientOptions into the appropriate date params, e.g. inserted_at.gte, inserted_at.lte, etc.

export function mergeDateRangeParams(options: FeedClientOptions) {
const { inserted_at_date_range, ...rest } = options;
if (!inserted_at_date_range) {
return rest;
}
const dateRangeParams: Record<string, string> = {};
// Determine which operators to use based on the inclusive flag
const isInclusive = inserted_at_date_range.inclusive ?? false;
// For start date: use gte if inclusive, gt if not
if (inserted_at_date_range.start) {
const startOperator = isInclusive ? "inserted_at.gte" : "inserted_at.gt";
dateRangeParams[startOperator] = inserted_at_date_range.start;
}
// For end date: use lte if inclusive, lt if not
if (inserted_at_date_range.end) {
const endOperator = isInclusive ? "inserted_at.lte" : "inserted_at.lt";
dateRangeParams[endOperator] = inserted_at_date_range.end;
}
return { ...rest, ...dateRangeParams };
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FeedResponse.meta type not updated to allow undefined

Medium Severity

The FeedResponse interface still defines meta: FeedMetadata as required, but when a socket-triggered fetch sends exclude: "meta", result.body.meta will be undefined at runtime. This response object is broadcast to messages.new legacy event listeners via FeedRealTimeCallback, whose type promises meta is always FeedMetadata. Consumers accessing resp.meta.total_count would get a TypeError. The FeedEventPayload.metadata type was correctly updated to optional, but FeedResponse.meta was not.

Additional Locations (1)

Fix in Cursor Fix in Web

// Unset options that should not be sent to the API
__loadingType: undefined;
__fetchSource: undefined;
Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/clients/feed/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ const initalizeStore = () => {
return {
...state,
items,
metadata: meta,
// Preserve existing metadata if meta is not provided
// (e.g., when excluded via `exclude` param)
metadata: meta ?? state.metadata,
pageInfo: options.shouldSetPage ? page_info : state.pageInfo,
loading: false,
networkStatus: NetworkStatus.ready,
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/clients/feed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export type BindableFeedEvent = FeedEvent | "items.received.*" | "items.*";
export interface FeedEventPayload<T = GenericData> {
event: Omit<FeedEvent, "messages.new">;
items: FeedItem<T>[];
metadata: FeedMetadata;
metadata?: FeedMetadata;
}

export type FeedRealTimeCallback = (resp: FeedResponse) => void;
Expand Down
24 changes: 24 additions & 0 deletions packages/client/src/clients/feed/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,27 @@ export function getFormattedTriggerData(options: FeedClientOptions) {

return undefined;
}

export function getFormattedExclude(options: FeedClientOptions) {
if (!options?.exclude?.length) {
return undefined;
}

const fields = options.exclude
.map((field) => field.trim())
.filter((field) => !!field);

return fields.length ? fields.join(",") : undefined;
}

/**
* Merges two arrays, deduplicating values.
* Returns undefined if the merged result is empty.
*/
export function mergeAndDedupeArrays<T>(
array1: T[] | undefined,
array2: T[] | undefined,
): T[] | undefined {
const merged = [...(array1 ?? []), ...(array2 ?? [])];
return merged.length ? [...new Set(merged)] : undefined;
}
143 changes: 140 additions & 3 deletions packages/client/test/clients/feed/feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,16 +1253,74 @@ describe("Feed", () => {
event: "new-message" as const,
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
data: {
client_ref_id: {
[feed.referenceId]: {
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
},
},
};

await feed.handleSocketEvent(newMessagePayload);

// Should trigger a fetch to get the latest data
expect(mockApiClient.makeRequest).toHaveBeenCalled();
// Should trigger a fetch to get the latest data with exclude: "meta"
expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
method: "GET",
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
params: {
archived: "exclude",
mode: "compact",
exclude: "meta",
},
});
} finally {
cleanup();
}
});

test("handles new message socket events without metadata in payload", async () => {
const { knock, mockApiClient, cleanup } = getTestSetup();

try {
const mockSocketManager = {
join: vi.fn().mockReturnValue(vi.fn()),
leave: vi.fn(),
};

// Mock the store response for the feed fetch
mockApiClient.makeRequest.mockResolvedValue({
statusCode: "ok",
body: {
entries: [],
page_info: { before: null, after: null },
meta: { total_count: 1, unread_count: 1, unseen_count: 1 },
},
});

const feed = new Feed(
knock,
"01234567-89ab-cdef-0123-456789abcdef",
{},
mockSocketManager as unknown as FeedSocketManager,
);

// Payload lacking metadata for this client's referenceId.
// This should never happen in practice, but we should handle it gracefully.
const newMessagePayload = {
event: "new-message" as const,
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
data: {},
};

await feed.handleSocketEvent(newMessagePayload);

// Should trigger a fetch WITHOUT exclude: "meta" to get badge counts from API
expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
method: "GET",
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
params: {
archived: "exclude",
mode: "compact",
},
});
} finally {
cleanup();
}
Expand Down Expand Up @@ -1677,4 +1735,83 @@ describe("Feed", () => {
}
});
});

describe("Exclude Option", () => {
test("converts exclude array to comma-separated string in query params", async () => {
const { knock, mockApiClient, cleanup } = getTestSetup();

try {
const mockFeedResponse = {
entries: [],
meta: { total_count: 0, unread_count: 0, unseen_count: 0 },
page_info: { before: null, after: null, page_size: 50 },
};

mockApiClient.makeRequest.mockResolvedValue({
statusCode: "ok",
body: mockFeedResponse,
});

const feed = new Feed(
knock,
"01234567-89ab-cdef-0123-456789abcdef",
{},
undefined,
);

await feed.fetch({
exclude: ["entries.archived_at", "meta.total_count"],
});

expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
method: "GET",
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
params: {
archived: "exclude",
mode: "compact",
exclude: "entries.archived_at,meta.total_count",
},
});
} finally {
cleanup();
}
});

test("ignores empty exclude array", async () => {
const { knock, mockApiClient, cleanup } = getTestSetup();

try {
const mockFeedResponse = {
entries: [],
meta: { total_count: 0, unread_count: 0, unseen_count: 0 },
page_info: { before: null, after: null, page_size: 50 },
};

mockApiClient.makeRequest.mockResolvedValue({
statusCode: "ok",
body: mockFeedResponse,
});

const feed = new Feed(
knock,
"01234567-89ab-cdef-0123-456789abcdef",
{},
undefined,
);

await feed.fetch({ exclude: [] });

expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
method: "GET",
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
params: {
archived: "exclude",
mode: "compact",
},
});
} finally {
cleanup();
}
});
});
});
26 changes: 26 additions & 0 deletions packages/client/test/clients/feed/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,32 @@ describe("feed store", () => {
const state = store.getState();
expect(state.items).toHaveLength(2); // Should deduplicate
});

test("preserves existing metadata when meta is undefined", () => {
const store = createStore();

// Set initial result with metadata
store.getState().setResult({
entries: mockItems,
meta: mockMetadata,
page_info: mockPageInfo,
});

// Verify initial metadata is set
expect(store.getState().metadata).toEqual(mockMetadata);

// Set new result without meta (simulating exclude param)
store.getState().setResult({
entries: [mockItems[0]!],
meta: undefined,
page_info: mockPageInfo,
});

// Metadata should be preserved
const state = store.getState();
expect(state.metadata).toEqual(mockMetadata);
expect(state.items).toHaveLength(1);
});
});

describe("setMetadata", () => {
Expand Down
Loading
Loading