diff --git a/.changeset/feed-initial-data-hydration.md b/.changeset/feed-initial-data-hydration.md new file mode 100644 index 000000000..e4b04eff0 --- /dev/null +++ b/.changeset/feed-initial-data-hydration.md @@ -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`. 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. diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index d78f053ae..8affbcf33 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -36,13 +36,74 @@ import { import { getFormattedTriggerData, mergeDateRangeParams } from "./utils"; // Default options to apply -const feedClientDefaults: Pick = { - archived: "exclude", - mode: "compact", -}; +export const feedClientDefaults: Pick = + { + archived: "exclude", + mode: "compact", + }; const CLIENT_REF_ID_PREFIX = "client_"; +function isPromise(value: T | Promise): value is Promise { + 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 { + 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; @@ -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(); @@ -77,7 +142,7 @@ 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}`); @@ -85,6 +150,62 @@ class Feed { 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`. + * 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) { + 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, + }); } /** @@ -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), }); if (result.statusCode === "error" || !result.body) { diff --git a/packages/client/src/clients/feed/index.ts b/packages/client/src/clients/feed/index.ts index d62505779..2712b3bb0 100644 --- a/packages/client/src/clients/feed/index.ts +++ b/packages/client/src/clients/feed/index.ts @@ -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; @@ -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 { + 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); } diff --git a/packages/client/src/clients/feed/interfaces.ts b/packages/client/src/clients/feed/interfaces.ts index 6e6085036..05b2e32f6 100644 --- a/packages/client/src/clients/feed/interfaces.ts +++ b/packages/client/src/clients/feed/interfaces.ts @@ -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` 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; } export type FetchFeedOptions = { __loadingType?: NetworkStatus.loading | NetworkStatus.fetchMore; __fetchSource?: "socket" | "http"; -} & Omit; +} & Omit< + FeedClientOptions, + "__experimentalCrossBrowserUpdates" | "initialData" +>; /** * The final data shape that is sent to the the list feed items endpoint of the Knock API. @@ -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; diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index 4ba7ff30f..c99691f65 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test, vi } from "vitest"; -import type { FetchFeedOptions } from "../../../src"; - +import type { FeedResponse, FetchFeedOptions } from "../../../src"; import Feed from "../../../src/clients/feed/feed"; import { FeedSocketManager } from "../../../src/clients/feed/socket-manager"; import { NetworkStatus } from "../../../src/networkStatus"; import { createMockFeedItems, + createSuccessfulFeedResponse, createUnreadFeedItem, } from "../../test-utils/fixtures"; import { authenticateKnock, createMockKnock } from "../../test-utils/mocks"; @@ -1038,7 +1038,6 @@ describe("Feed", () => { }); }); - describe("Socket Event Handling", () => { test("handles new message socket events", async () => { const { knock, mockApiClient, cleanup } = getTestSetup(); @@ -1494,4 +1493,116 @@ describe("Feed", () => { } }); }); + + describe("Feed Hydration", () => { + const FEED_ID = "01234567-89ab-cdef-0123-456789abcdef"; + + test("seeds the store from synchronous initialData", () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + const initialData = createSuccessfulFeedResponse( + createMockFeedItems(3), + ).body; + + const feed = new Feed(knock, FEED_ID, { initialData }, undefined); + + expect(feed.store.getState().items).toHaveLength(3); + expect(feed.store.getState().networkStatus).toBe(NetworkStatus.ready); + // The feed renders from seeded data without hitting the network + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("does not forward initialData as a fetch query param", async () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + const initialData = createSuccessfulFeedResponse( + createMockFeedItems(2), + ).body; + mockApiClient.makeRequest.mockResolvedValue( + createSuccessfulFeedResponse(createMockFeedItems(2)), + ); + + const feed = new Feed(knock, FEED_ID, { initialData }, undefined); + await feed.fetch(); + + const params = mockApiClient.makeRequest.mock.calls[0][0].params; + expect(params).not.toHaveProperty("initialData"); + } finally { + cleanup(); + } + }); + + test("seeds the store once a streamed initialData promise resolves", async () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + const response = createSuccessfulFeedResponse( + createMockFeedItems(4), + ).body; + let resolveInitialData: (value: FeedResponse) => void = () => {}; + const initialData = new Promise((resolve) => { + resolveInitialData = resolve; + }); + + const feed = new Feed(knock, FEED_ID, { initialData }, undefined); + + // While pending, the feed reports loading so the UI can show a skeleton + // and a concurrent mount-time fetch is deferred. + expect(feed.store.getState().networkStatus).toBe(NetworkStatus.loading); + expect(feed.store.getState().items).toHaveLength(0); + + resolveInitialData(response); + await initialData; + + expect(feed.store.getState().items).toHaveLength(4); + expect(feed.store.getState().networkStatus).toBe(NetworkStatus.ready); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("sets an error network status when the initialData promise rejects", async () => { + const { knock, cleanup } = getTestSetup(); + + try { + const initialData = Promise.reject(new Error("boom")); + + const feed = new Feed(knock, FEED_ID, { initialData }, undefined); + + await initialData.catch(() => {}); + + expect(feed.store.getState().networkStatus).toBe(NetworkStatus.error); + } finally { + cleanup(); + } + }); + + test("broadcasts items.received.page when hydrated imperatively", () => { + const { knock, cleanup } = getTestSetup(); + + try { + const feed = new Feed(knock, FEED_ID, {}, undefined); + const handler = vi.fn(); + feed.on("items.received.page", handler); + + const response = createSuccessfulFeedResponse( + createMockFeedItems(2), + ).body; + feed.hydrate(response); + + expect(feed.store.getState().items).toHaveLength(2); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ event: "items.received.page" }), + ); + } finally { + cleanup(); + } + }); + }); }); diff --git a/packages/client/test/clients/feed/index.test.ts b/packages/client/test/clients/feed/index.test.ts index 9a612f61c..4682021b8 100644 --- a/packages/client/test/clients/feed/index.test.ts +++ b/packages/client/test/clients/feed/index.test.ts @@ -277,4 +277,94 @@ describe("FeedClient", () => { expect(feed2.defaultOptions.status).toBe("read"); }); }); + + describe("prefetch", () => { + const makeRequest = vi.fn(); + const isAuthenticated = vi.fn(() => true); + const prefetchKnock = { + userId: "user_123", + userToken: "token_456", + log: vi.fn(), + isAuthenticated, + failIfNotAuthenticated: vi.fn(() => { + if (!isAuthenticated()) { + throw new Error( + "Not authenticated. Please call `authenticate` first.", + ); + } + }), + client: vi.fn(() => ({ socket: mockSocket, makeRequest })), + feeds: {}, + } as unknown as Knock; + + beforeEach(() => { + makeRequest.mockReset(); + isAuthenticated.mockReturnValue(true); + }); + + test("returns the feed response and sends the same request as fetch", async () => { + const body = { + entries: [], + meta: { total_count: 0, unread_count: 0, unseen_count: 0 }, + page_info: { before: null, after: null, page_size: 50 }, + }; + makeRequest.mockResolvedValue({ statusCode: "ok", body }); + + const feedClient = new FeedClient(prefetchKnock); + const result = await feedClient.prefetch(validFeedId, { + trigger_data: { organization_id: "org_1" }, + }); + + expect(result).toEqual(body); + // Mirrors Feed.fetch: defaults applied and trigger_data stringified + expect(makeRequest).toHaveBeenCalledWith({ + method: "GET", + url: `/v1/users/user_123/feeds/${validFeedId}`, + params: { + archived: "exclude", + mode: "compact", + trigger_data: JSON.stringify({ organization_id: "org_1" }), + }, + }); + }); + + test("does not create a feed instance or open a socket", async () => { + makeRequest.mockResolvedValue({ + statusCode: "ok", + body: { + entries: [], + meta: { total_count: 0, unread_count: 0, unseen_count: 0 }, + page_info: { before: null, after: null, page_size: 50 }, + }, + }); + + const feedClient = new FeedClient(prefetchKnock); + await feedClient.prefetch(validFeedId); + + expect(mockSocket.connect).not.toHaveBeenCalled(); + expect(feedClient["feedInstances"]).toHaveLength(0); + }); + + test("rejects when the request fails", async () => { + makeRequest.mockResolvedValue({ + statusCode: "error", + error: new Error("nope"), + }); + + const feedClient = new FeedClient(prefetchKnock); + + await expect(feedClient.prefetch(validFeedId)).rejects.toThrow("nope"); + }); + + test("rejects when the Knock instance is not authenticated", async () => { + isAuthenticated.mockReturnValue(false); + + const feedClient = new FeedClient(prefetchKnock); + + await expect(feedClient.prefetch(validFeedId)).rejects.toThrow( + "Not authenticated", + ); + expect(makeRequest).not.toHaveBeenCalled(); + }); + }); });