Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/feed-initial-data-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@knocklabs/client": minor
---

Add server-to-client feed prefetching, modeled on TanStack Query's `prefetchQuery`/`initialData`/`hydrate`:

- `FeedClient.prefetch()` fetches a user's feed once on the server (no feed instance, socket, or store) and returns a `FeedResponse`. It sends the same request as `Feed.fetch`, so the prefetched data matches what the client would fetch on mount.
- New `initialData` feed option (and `Feed.hydrate`) seeds the feed store from a `FeedResponse` or a `Promise<FeedResponse>`. The promise form supports streaming/deferred data: a server loader can fire off `prefetch` without awaiting it and hand the pending promise to the client, which renders the feed on first paint instead of an empty/loading state.
152 changes: 127 additions & 25 deletions packages/client/src/clients/feed/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,74 @@ import {
import { getFormattedTriggerData, mergeDateRangeParams } from "./utils";

// Default options to apply
const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
archived: "exclude",
mode: "compact",
};
export const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> =
{
archived: "exclude",
mode: "compact",
};

const CLIENT_REF_ID_PREFIX = "client_";

function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { then?: unknown }).then === "function"
);
}

/**
* Builds the query params sent to the list feed items endpoint. Shared by
* `Feed.fetch` and `FeedClient.prefetch` so the request never drifts between
* the two.
*/
function buildFeedRequestParams(
defaultOptions: FeedClientOptions,
options: FetchFeedOptions,
): FetchFeedOptionsForRequest {
return {
...defaultOptions,
...mergeDateRangeParams(options),
// trigger_data should be a JSON string for the API; this formats it if it's
// an object. https://docs.knock.app/reference#get-feed
trigger_data: getFormattedTriggerData({ ...defaultOptions, ...options }),
// Unset options that should not be sent to the API
__loadingType: undefined,
__fetchSource: undefined,
__experimentalCrossBrowserUpdates: undefined,
};
}

/**
* Issues the GET request for a user's feed and returns the response body as a
* {@link FeedResponse}, throwing on a non-ok response. This is the shared
* transport for `Feed.fetch` (which writes the result into its store) and
* `FeedClient.prefetch` (which returns it for server-side hydration), so both
* send the same request and parse the same shape.
*/
export async function fetchFeed(
knock: Knock,
feedId: string,
defaultOptions: FeedClientOptions,
options: FetchFeedOptions = {},
): Promise<FeedResponse> {
const result = await knock.client().makeRequest({
method: "GET",
url: `/v1/users/${knock.userId}/feeds/${feedId}`,
params: buildFeedRequestParams(defaultOptions, options),
});

if (result.statusCode === "error" || !result.body) {
throw result.error ?? new Error("[Knock] Failed to fetch feed");
}

return {
entries: result.body.entries,
meta: result.body.meta,
page_info: result.body.page_info,
};
}

class Feed {
public readonly defaultOptions: FeedClientOptions;
public readonly referenceId: string;
Expand All @@ -69,6 +130,10 @@ class Feed {
);
}

// `initialData` is consumed locally to seed the store; it must not leak into
// `defaultOptions`, otherwise it would be sent as a query param on every fetch.
const { initialData, ...feedOptions } = options;

this.feedId = feedId;
this.userFeedId = this.buildUserFeedId();
this.referenceId = CLIENT_REF_ID_PREFIX + nanoid();
Expand All @@ -77,14 +142,70 @@ class Feed {
this.broadcaster = new EventEmitter({ wildcard: true, delimiter: "." });
this.defaultOptions = {
...feedClientDefaults,
...mergeDateRangeParams(options),
...mergeDateRangeParams(feedOptions),
};
this.knock.log(`[Feed] Initialized a feed on channel ${feedId}`);

// Attempt to setup a realtime connection (does not join)
this.initializeRealtimeConnection();

this.setupBroadcastChannel();

// Seed the store with server-fetched data (or a promise of it) so the feed
// renders content on first paint instead of an empty/loading state.
if (initialData) {
this.hydrate(initialData);
}
}

/**
* Seeds the feed store with a feed response fetched ahead of time, typically on
* the server. This is the imperative equivalent of the `initialData` feed
* option and is modeled on TanStack Query's `hydrate`.
*
* Accepts either a resolved {@link FeedResponse} or a `Promise<FeedResponse>`.
* The promise form supports streaming / deferred data: a server loader can
* fire off the fetch without awaiting it and hand the pending promise to the
* client, which seeds the store once the framework resolves it. While pending,
* the feed reports a `loading` network status (which also defers a concurrent
* mount-time {@link fetch}, avoiding a duplicate request).
*/
hydrate(initialData: FeedResponse | Promise<FeedResponse>) {
if (isPromise(initialData)) {
// Reflect that data is on the way until the streamed promise resolves.
this.store.getState().setNetworkStatus(NetworkStatus.loading);

initialData.then(
(response) => this.setInitialResult(response),
(error) => {
this.knock.log(
`[Feed] Failed to hydrate feed from initialData promise: ${error}`,
);
this.store.getState().setNetworkStatus(NetworkStatus.error);
},
);

return;
}

this.setInitialResult(initialData);
}

/**
* Applies a feed response to the store and broadcasts a feed event, mirroring
* the side effects of a non-paginated {@link fetch} so event-driven consumers
* are notified of the seeded page.
*/
private setInitialResult(response: FeedResponse) {
this.store.getState().setResult(response);

const feedEventType: FeedEvent = "items.received.page";

this.broadcast(feedEventType, {
items: response.entries as FeedItem[],
metadata: response.meta as FeedMetadata,
event: feedEventType,
});
}

/**
Expand Down Expand Up @@ -524,29 +645,10 @@ class Feed {
// Set the loading type based on the request type it is
state.setNetworkStatus(options.__loadingType ?? NetworkStatus.loading);

// 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,
});

// Always include the default params, if they have been set
const queryParams: FetchFeedOptionsForRequest = {
...this.defaultOptions,
...mergeDateRangeParams(options),
trigger_data: formattedTriggerData,
// Unset options that should not be sent to the API
__loadingType: undefined,
__fetchSource: undefined,
__experimentalCrossBrowserUpdates: undefined,
};

const result = await this.knock.client().makeRequest({
method: "GET",
url: `/v1/users/${this.knock.userId}/feeds/${this.feedId}`,
params: queryParams,
params: buildFeedRequestParams(this.defaultOptions, options),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fetch loading hides synced data

Medium Severity

When initialData is a resolved FeedResponse, hydration sets the store to ready with items, but a first-page fetch() still sets networkStatus to loading. UIs that hide feed items while status is loading can drop server-seeded content until the request completes, which conflicts with rendering hydrated data on first paint.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 048cd17. Configure here.

});

if (result.statusCode === "error" || !result.body) {
Expand Down
33 changes: 31 additions & 2 deletions packages/client/src/clients/feed/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import Knock from "../../knock";

import Feed from "./feed";
import { FeedClientOptions } from "./interfaces";
import Feed, { feedClientDefaults, fetchFeed } from "./feed";
import {
FeedClientOptions,
FeedResponse,
FetchFeedOptions,
} from "./interfaces";
import { FeedSocketManager } from "./socket-manager";
import { mergeDateRangeParams } from "./utils";

class FeedClient {
private instance: Knock;
Expand All @@ -26,6 +31,30 @@ class FeedClient {
return feedInstance;
}

/**
* Fetches a user's feed once and returns the response, without creating a
* feed instance, opening a socket, or touching any store. Intended for
* server-side prefetching: pair the returned `FeedResponse` (or its promise)
* with the `initialData` feed option to render the feed on first paint.
*
* Sends the same request as `Feed.fetch`, so the prefetched data matches what
* the client would otherwise fetch on mount. Throws if the Knock instance is
* not authenticated.
*/
async prefetch(
feedChannelId: string,
options: FetchFeedOptions = {},
): Promise<FeedResponse> {
this.instance.failIfNotAuthenticated();

const defaultOptions: FeedClientOptions = {
...feedClientDefaults,
...mergeDateRangeParams(options),
};

return fetchFeed(this.instance, feedChannelId, defaultOptions, {});
}

removeInstance(feed: Feed) {
this.feedInstances = this.feedInstances.filter((f) => f !== feed);
}
Expand Down
28 changes: 26 additions & 2 deletions packages/client/src/clients/feed/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,36 @@ export interface FeedClientOptions {
* @default "compact"
*/
mode?: "rich" | "compact";
/**
* A feed response fetched ahead of time (typically on the server) used to seed
* the feed store on initialization, so the feed renders with content on first
* paint instead of an empty/loading state.
*
* Inspired by TanStack Query's `initialData`: the value is a plain
* {@link FeedResponse}, which is exactly the JSON body returned by the
* list feed items endpoint — already serializable, so no separate
* dehydrate step is required to pass it from the server to the client.
*
* A `Promise<FeedResponse>` is also accepted. This supports streaming /
* deferred data: a server loader can kick off the fetch without awaiting it
* and hand the (still-pending) promise to the client, which seeds the store
* once the framework resolves it. While the promise is pending the feed
* reports a `loading` network status, which also defers a concurrent
* mount-time `fetch()` so the client doesn't duplicate the server request.
*
* The feed still connects to the realtime service and reconciles on its next
* fetch, so seeded data is replaced as soon as fresh data arrives.
*/
initialData?: FeedResponse | Promise<FeedResponse>;
}

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

/**
* The final data shape that is sent to the the list feed items endpoint of the Knock API.
Expand All @@ -67,7 +91,7 @@ export type FetchFeedOptions = {
*/
export type FetchFeedOptionsForRequest = Omit<
FeedClientOptions,
"trigger_data" | "inserted_at_date_range"
"trigger_data" | "inserted_at_date_range" | "initialData"
> & {
/** The trigger data of the feed items (as a JSON string). */
trigger_data?: string;
Expand Down
Loading