Skip to content

Commit

Permalink
fix(kno-7523): fixes bad returns from useNotificationStore (#439)
Browse files Browse the repository at this point in the history
* fix(kno-7523): fixes bad returns from useNotificationStore

* adds better types and comments:

* fixes hook name

* adds tests
  • Loading branch information
MikeCarbone authored Mar 5, 2025
1 parent ef991fd commit a25d1e5
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 29 deletions.
78 changes: 78 additions & 0 deletions examples/nextjs-example/pages/custom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { MarkdownContentBlock } from "@knocklabs/client";
import {
KnockProvider,
useKnockClient,
useNotificationStore,
useNotifications,
} from "@knocklabs/react";
import { useCallback, useEffect } from "react";

import useIdentify from "../hooks/useIdentify";

// Follows this guide as setup to create a custom notifications UI
// https://docs.knock.app/in-app-ui/react/custom-notifications-ui

function ProviderComponent({ children }: { children: React.ReactNode }) {
const { userId, userToken } = useIdentify();

const tokenRefreshHandler = useCallback(async () => {
// Refresh the user token 1s before it expires
const res = await fetch(`/api/auth?id=${userId}`);
const json = await res.json();

return json.userToken;
}, [userId]);

return (
<KnockProvider
userId={userId}
userToken={userToken}
apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_API_KEY!}
host={process.env.NEXT_PUBLIC_KNOCK_HOST}
onUserTokenExpiring={tokenRefreshHandler}
timeBeforeExpirationInMs={5000}
logLevel="debug"
>
{children}
</KnockProvider>
);
}

function NotificationFeed() {
const knockClient = useKnockClient();
const feedClient = useNotifications(
knockClient,
process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID as string,
);

const { items, metadata } = useNotificationStore(feedClient);

console.log({ items, metadata });

useEffect(() => {
feedClient.fetch();
}, [feedClient]);

return (
<div className="notifications">
<span>You have {metadata.unread_count} unread items</span>
{items.map((item) => (
<div key={item.id}>
<div
dangerouslySetInnerHTML={{
__html: (item.blocks[0] as MarkdownContentBlock).rendered,
}}
/>
</div>
))}
</div>
);
}

export default function Home() {
return (
<ProviderComponent>
<NotificationFeed />
</ProviderComponent>
);
}
1 change: 1 addition & 0 deletions packages/react-core/src/modules/feed/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { default as useFeedSettings } from "./useFeedSettings";
export {
default as useNotificationStore,
useCreateNotificationStore,
type Selector,
} from "./useNotificationStore";
52 changes: 23 additions & 29 deletions packages/react-core/src/modules/feed/hooks/useNotificationStore.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,33 @@
import { Feed, type FeedStoreState } from "@knocklabs/client";
import { type StoreApi, type UseBoundStore, useStore } from "zustand";

// A hook designed to create a `UseBoundStore` instance.
// https://zustand.docs.pmnd.rs/guides/typescript#bounded-usestore-hook-for-vanilla-stores
function useCreateNotificationStore(
feedClient: Feed,
): UseBoundStore<StoreApi<FeedStoreState>>;
function useCreateNotificationStore<T, U = T>(
feedClient: Feed,
externalSelector: (state: FeedStoreState) => U,
): U;
export type Selector<T> = (state: FeedStoreState) => T;

/**
* Access a Bounded Store instance
* Access a Bounded Store instance by converting our vanilla store to a UseBoundStore
* https://zustand.docs.pmnd.rs/guides/typescript#bounded-usestore-hook-for-vanilla-stores
* Allow passing a selector down from useCreateNotificationStore OR useNotificationStore
* We'll favor the the one passed later outside of useCreateNotificationStore instantiation
*/
function useCreateNotificationStore<T, U = T>(
function useCreateNotificationStore<T>(
feedClient: Feed,
externalSelector?: (state: FeedStoreState) => U,
) {
const store = useStore(feedClient.store);

return (selector?: (state: FeedStoreState) => U) => {
const innerSelector = selector ?? externalSelector;
return innerSelector ? innerSelector(store) : store;
};
): UseBoundStore<StoreApi<FeedStoreState>> {
// Keep selector optional for external use
// useStore requires a selector so we'll pass in a default one when not provided
const useBoundedStore = (selector?: Selector<T>) =>
useStore(feedClient.store, selector ?? ((state) => state as T));
return useBoundedStore as UseBoundStore<StoreApi<FeedStoreState>>;
}

/**
* A hook used to access content within the notification store.
*
* @example
*
* ```ts
* const { items, metadata } = useNotificationStore(feedClient);
* ```
*
* A selector can be used to access a subset of the store state.
*
* @example
Expand All @@ -41,18 +39,14 @@ function useCreateNotificationStore<T, U = T>(
* }));
* ```
*/
function useNotificationStore(
feedClient: Feed,
): UseBoundStore<StoreApi<FeedStoreState>>;
function useNotificationStore(feedClient: Feed): FeedStoreState;
function useNotificationStore<T>(feedClient: Feed, selector: Selector<T>): T;
function useNotificationStore<T>(
feedClient: Feed,
selector: (state: FeedStoreState) => T,
): T;
function useNotificationStore<T, U = T>(
feedClient: Feed,
selector?: (state: FeedStoreState) => U,
) {
return useCreateNotificationStore(feedClient, selector!);
selector?: Selector<T>,
): T | FeedStoreState {
const useStoreLocal = useCreateNotificationStore(feedClient);
return useStoreLocal(selector ?? ((state) => state as T));
}

export { useCreateNotificationStore };
Expand Down
113 changes: 113 additions & 0 deletions packages/react-core/test/feed/useNotificationStore.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Knock, { Feed, type FeedMetadata } from "@knocklabs/client";
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import useNotificationStore, { type Selector, useCreateNotificationStore } from "../../src/modules/feed/hooks/useNotificationStore";

describe("useCreateNotificationStore", () => {
const knock = new Knock("test");
const feedClient = new Feed(knock, "test", {});

it("returns a hook you can use to access the store with a selector", () => {
const useFeedStore = useCreateNotificationStore(feedClient);
const { result } = renderHook(() => useFeedStore((state) => ({
metadata: state.metadata,
})));

expect(result.current).toEqual({
metadata: {
total_count: 0,
unread_count: 0,
unseen_count: 0,
},
});
});

it("returns a hook you can use to access the store without a selector", () => {
const useFeedStore = useCreateNotificationStore(feedClient);
const { result } = renderHook(() => useFeedStore());

expect(result.current).toEqual(
expect.objectContaining({
items: [],
metadata: expect.objectContaining({
total_count: 0,
unread_count: 0,
unseen_count: 0,
}),
})
);
expect(Object.keys(result.current).length).toBeGreaterThan(2);
});

it("returns a bound store that can be used with a selector", () => {
const { result } = renderHook(() => {
const useStore = useCreateNotificationStore(feedClient);
return useStore((state) => state.metadata);
});

expect(result.current).toEqual({
total_count: 0,
unread_count: 0,
unseen_count: 0,
});
});

it("returns a function", () => {
const useFeedStore = useCreateNotificationStore(feedClient);
expect(typeof useFeedStore).toBe("function");
});
});


describe("useNotificationStore", () => {
const knock = new Knock("test");

const feedClient = new Feed(knock, "test", {});

it("returns the full store state when no selector is provided", () => {
const { result } = renderHook(() => useNotificationStore(feedClient));

expect(result.current).toEqual(
expect.objectContaining({
items: [],
metadata: expect.objectContaining({
total_count: 0,
unread_count: 0,
unseen_count: 0,
}),
})
);
expect(Object.keys(result.current).length).toBeGreaterThan(2);
});

it("returns selected state when selector is provided", () => {
const selector: Selector<FeedMetadata> = (state) => state.metadata;
const { result } = renderHook(() => useNotificationStore(feedClient, selector));

expect(result.current).toEqual({
total_count: 0,
unread_count: 0,
unseen_count: 0,
});
});

it("returns the same store reference on multiple calls", () => {
const { result: result1 } = renderHook(() => useNotificationStore(feedClient));
const { result: result2 } = renderHook(() => useNotificationStore(feedClient));

expect(result1.current).toEqual(result2.current);
});

it("returns an object without a selector", () => {
const { result } = renderHook(() => useNotificationStore(feedClient));
expect(typeof result.current).toBe("object");
expect(result.current).not.toBeNull();
});

it("returns an object with a selector", () => {
const selector: Selector<FeedMetadata> = (state) => state.metadata;
const { result } = renderHook(() => useNotificationStore(feedClient, selector));
expect(typeof result.current).toBe("object");
expect(result.current).not.toBeNull();
});
});

0 comments on commit a25d1e5

Please sign in to comment.