Browse Source

refactor(spec): apply PR review feedback (type safety, SWR-native read state, UI consistency)

Ryotaro Nagahara 1 month ago
parent
commit
7801461150

+ 9 - 4
.kiro/specs/news-inappnotification/design.md

@@ -441,11 +441,13 @@ collapsed モードで `overflow-auto + maxHeight` を使い、dock/drawer モ
 
 対策として `InAppNotificationContent` 内で `useSidebarMode()` を呼び、`isCollapsedMode()` が true のときのみ `overflow-auto` クラスと `maxHeight: 60vh` を付与する。dock/drawer モードでは div に何も付与せず、SimpleBar にスクロールを委ねる。
 
-**通知ドット即時消去のローカル state 戦略(実装後に判明した設計上の決定)**:
+**通知ドット即時消去: SWR mutate による楽観的更新**:
 
-`InAppNotificationElm` はクリック時に `apiv3Post('/in-app-notification/open')` でサーバーへ書き込みを行うが、UI への反映は SWR の再フェッチに依存する。`InAppNotificationContent` 内で `useSWRInfinite` の `mutate(updater, { revalidate: false })` を使って楽観的更新を試みたが、ナビゲーション(`<a href>`)でコンポーネントがアンマウントされると `useSWRInfinite` のページ単位キャッシュが古い状態に戻り、再マウント時にドットが復活する問題があった
+`InAppNotificationElm` はクリック時に `apiv3Post('/in-app-notification/open')` でサーバーへ書き込みを行うが、UI への反映は SWR キャッシュの即時書き換えで行う。`InAppNotificationContent` 内で `notificationResponse.mutate(updater, { revalidate: false })` を用い、`useSWRInfinite` のページごとに該当 `doc.status` を `STATUS_OPENED` へ書き換える
 
-対策として `InAppNotificationContent` に `useState<Set<string>>` を持ち、ユーザーがクリックした通知 ID をローカルに記録する。各 `InAppNotificationElm` のレンダリング時にこの set を参照し、ID が含まれる場合は `notification.status` を `STATUS_OPENED` にオーバーライドして渡す。これにより SWR キャッシュの状態によらず確実に即時反映される。
+`useSWRInfinite` のキャッシュは `SWRConfig` プロバイダの Map に保持されるため、同一 React tree のアンマウント/リマウントを跨いで状態が維持され、リマウント後もドットは消えたままとなる。ローカル `useState` を持たずに SWR の標準機能のみで完結させることで、キャッシュ・再検証制御・キー共有といった SWR の利点をそのまま活かせる。
+
+品質改善の経緯: PR #10986 のレビュー FB を受け、当初採用した `useState<Set<string>>` 戦略を SWR `mutate` + `revalidate: false` に差し替えた。実機検証で SWRConfig のグローバル Map がアンマウント/リマウント間でキャッシュを保持することを確認し、ドット復活の再発がないことを確認した。
 
 ---
 
@@ -462,7 +464,10 @@ collapsed モードで `overflow-auto + maxHeight` を使い、dock/drawer モ
   - 左端: 未読ドット(`bg-primary` 8px 丸)または同幅の透明スペーサー
   - アバター位置: `emoji` を表示(`UserPicture` が占める位置と同等)。未設定時は `📢` をフォールバック
   - コンテンツ列: タイトル(未読時 `fw-bold`、既読時 `fw-normal`)+ 公開日時
-- ロケールフォールバック: `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
+- ロケールフォールバック: `i18n.language → ja_JP → en_US → 最初に利用可能なキー`(`useTranslation()` から取得)
+- 日付フォーマット: `date-fns` の `format` と `getLocale(i18n.language)` を用い、`ActivityListItem` と同じロケールパターンに統一
+- Bootstrap クラス: `w-100 text-start bg-transparent fs-5 lh-1` などを利用し、インラインスタイルを最小化
+- 未読ドット: `InAppNotificationElm` と共有の `UnreadDot.module.scss` を使用し、両者の見た目を完全に揃える
 - クリック時: `POST /mark-read` + SWR mutate + `url` があれば新タブで開く
 
 ---

+ 21 - 0
.kiro/specs/news-inappnotification/tasks.md

@@ -164,3 +164,24 @@
   - `InAppNotificationSubstance.tsx` の `handleNotificationRead` で `useSWRInfinite` の `mutate(updater, { revalidate: false })` を使って既読状態をキャッシュに書き込もうとしていたが、ナビゲーション(`<a href>`)によってコンポーネントがアンマウントされた後に `useSWRInfinite` のページ単位キャッシュが古い状態に戻るため、ドットが再表示される
   - `useState<Set<string>>` でローカルに開封済み通知 ID を管理し、各 `InAppNotificationElm` のレンダリング時に `status` をその場でオーバーライドすることで、SWR キャッシュに依存せず即時反映を実現する
   - _Requirements: 6.1, 6.2_
+
+- [x] 13. PR レビュー FB 対応によるコード品質改善
+- [x] 13.1 型アサーションを排除する(FB ①)
+  - `interfaces/in-app-notification.ts` に `IInAppNotificationHasId = IInAppNotification & HasObjectId` を追加
+  - `stores/in-app-notification.ts` で `apiv3Get<InAppNotificationPaginateResult>()` にジェネリクスを注入し、`response.data as ...` を削除
+  - `InAppNotificationSubstance.tsx` の `allModeSWRResponse` を `SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>` として明示的に宣言し、`as unknown as Parameters<typeof InfiniteScroll>[0]...` を撤去
+  - `notificationResponse.data.flatMap(...) as (IInAppNotification & HasObjectId)[]` の cast を削除(型情報が自然に流れる)
+  - _Requirements: 品質改善_
+- [x] 13.2 SWR state-less による未読ドット即時消去へ差し替える(FB ②)
+  - 12.3 で採用した `useState<Set<string>>` を撤去し、`notificationResponse.mutate((pages) => ..., { revalidate: false })` による SWR ネイティブの楽観更新に置換
+  - `SWRConfig` プロバイダのキャッシュ Map がアンマウント/リマウントを跨いで保持されるため、再マウント時もドットは消えたまま(実機検証済み)
+  - SWR のキャッシュ・hook の利点を損なわない実装とする
+  - _Requirements: 品質改善, 6.1, 6.2_
+- [x] 13.3 NewsItem の言語ユーティリティと Bootstrap クラスを既存パターンに統一する(FB ③)
+  - `navigator.language` の独自ロジックを撤去し、`useTranslation()` の `i18n.language` を使用(`ActivityListItem` と同パターン)
+  - 日付表示を `date-fns` `format` + `getLocale(i18n.language)` に統一
+  - button のインラインスタイル(`cursor/width/textAlign/background`)を Bootstrap クラス `w-100 text-start bg-transparent` に置換
+  - emoji span の `fontSize/lineHeight` を `fs-5 lh-1` に置換
+  - 未読ドットのインラインスタイルを `UnreadDot.module.scss` の共通 CSS Module に抽出し、`NewsItem.tsx` と `InAppNotificationElm.tsx` の両者から参照して見た目を統一
+  - `browserLanguage` prop を廃止し、テストも i18n モックへ合わせて更新
+  - _Requirements: 品質改善_

+ 3 - 7
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -10,6 +10,8 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import { useModelNotification } from './ModelNotification';
 
+import unreadDotStyles from './UnreadDot.module.scss';
+
 interface Props {
   notification: IInAppNotification & HasObjectId;
   onUnopenedNotificationOpend?: () => void;
@@ -79,13 +81,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
               notification.status === InAppNotificationStatuses.STATUS_UNOPENED
                 ? 'bg-primary'
                 : ''
-            } rounded-circle me-3`}
-            style={{
-              width: 8,
-              height: 8,
-              minWidth: 8,
-              display: 'inline-block',
-            }}
+            } rounded-circle me-3 ${unreadDotStyles['unread-dot']}`}
           />
 
           {renderActionUserPictures()}

+ 6 - 0
apps/app/src/client/components/InAppNotification/UnreadDot.module.scss

@@ -0,0 +1,6 @@
+.unread-dot {
+  width: 8px;
+  height: 8px;
+  min-width: 8px;
+  display: inline-block;
+}

+ 52 - 68
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,6 +1,6 @@
-import { type JSX, useId, useMemo, useState } from 'react';
-import type { HasObjectId } from '@growi/core';
+import { type JSX, useId, useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
+import type { SWRInfiniteResponse } from 'swr/infinite';
 
 import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
 import InfiniteScroll from '~/client/components/InfiniteScroll';
@@ -10,7 +10,10 @@ import {
   useSWRxNewsUnreadCount,
 } from '~/features/news/client/hooks/use-news';
 import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
-import type { IInAppNotification } from '~/interfaces/in-app-notification';
+import type {
+  IInAppNotificationHasId,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { useSidebarMode } from '~/states/ui/sidebar';
 import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
@@ -93,7 +96,7 @@ type MergedItem =
   | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
   | {
       type: 'notification';
-      item: IInAppNotification & HasObjectId;
+      item: IInAppNotificationHasId;
       sortKey: Date;
     };
 
@@ -104,12 +107,6 @@ export const InAppNotificationContent = (
   const { t } = useTranslation('commons');
   const { isCollapsedMode } = useSidebarMode();
 
-  // Track locally-opened notifications to give instant dot removal without
-  // relying on SWR cache persistence across navigation/unmount cycles.
-  const [locallyOpenedNotifIds, setLocallyOpenedNotifIds] = useState<
-    Set<string>
-  >(new Set());
-
   // In collapsed mode (hover panel): constrain height + own scrollbar
   // In dock/drawer mode: no constraints — outer SimpleBar handles all scrolling
   const collapsed = isCollapsedMode();
@@ -139,13 +136,10 @@ export const InAppNotificationContent = (
     return newsResponse.data.flatMap((page) => page.docs);
   }, [newsResponse.data]);
 
-  const allNotificationItems: (IInAppNotification & HasObjectId)[] =
-    useMemo(() => {
-      if (!notificationResponse.data) return [];
-      return notificationResponse.data.flatMap(
-        (page) => page.docs,
-      ) as (IInAppNotification & HasObjectId)[];
-    }, [notificationResponse.data]);
+  const allNotificationItems: IInAppNotificationHasId[] = useMemo(() => {
+    if (!notificationResponse.data) return [];
+    return notificationResponse.data.flatMap((page) => page.docs);
+  }, [notificationResponse.data]);
 
   // Determine if each stream has exhausted its pages
   const newsExhausted = useMemo(
@@ -165,8 +159,12 @@ export const InAppNotificationContent = (
     [notificationResponse.data],
   );
 
-  // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode
-  const allModeSWRResponse = useMemo(
+  // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode.
+  // Typed to match newsResponse's shape so InfiniteScroll<E> receives a
+  // well-typed response without `as unknown as` casts.
+  const allModeSWRResponse = useMemo<
+    SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>
+  >(
     () => ({
       data: newsResponse.data,
       error: newsResponse.error ?? notificationResponse.error,
@@ -174,29 +172,22 @@ export const InAppNotificationContent = (
         newsResponse.isValidating || notificationResponse.isValidating,
       isLoading: newsResponse.isLoading || notificationResponse.isLoading,
       mutate: newsResponse.mutate,
-      setSize: (updater: number | ((size: number) => number)) => {
-        const promises: Promise<unknown>[] = [];
-        if (!newsExhausted) {
-          promises.push(
-            newsResponse.setSize(
-              typeof updater === 'function'
-                ? updater(newsResponse.size)
-                : updater,
-            ),
-          );
-        }
-        if (!notifExhausted) {
-          promises.push(
-            notificationResponse.setSize(
-              typeof updater === 'function'
-                ? updater(notificationResponse.size)
-                : updater,
-            ),
-          );
-        }
-        return Promise.all(promises) as unknown as Promise<
-          (typeof newsResponse.data)[]
-        >;
+      setSize: async (updater) => {
+        const nextNewsSize =
+          typeof updater === 'function' ? updater(newsResponse.size) : updater;
+        const nextNotifSize =
+          typeof updater === 'function'
+            ? updater(notificationResponse.size)
+            : updater;
+        const [newsResult] = await Promise.all([
+          newsExhausted
+            ? Promise.resolve(newsResponse.data)
+            : newsResponse.setSize(nextNewsSize),
+          notifExhausted
+            ? Promise.resolve(notificationResponse.data)
+            : notificationResponse.setSize(nextNotifSize),
+        ]);
+        return newsResult;
       },
       size: Math.max(newsResponse.size, notificationResponse.size),
     }),
@@ -231,12 +222,23 @@ export const InAppNotificationContent = (
     mutateNewsUnreadCount();
   };
 
-  // Use local state to immediately remove the unread dot on click.
-  // Relying solely on SWR mutate is unreliable because useSWRInfinite per-page
-  // caches can be stale after navigation/unmount, so the dot reappears on
-  // remount even with revalidate:false.
+  // SWR-idiomatic optimistic update: rewrite the per-page cache in place and
+  // suppress revalidation so the dot stays removed across unmount/remount.
+  // The useSWRInfinite cache is held in the global SWR provider keyed by the
+  // composite list key, so subsequent mounts read this updated cache directly.
   const handleNotificationRead = (notificationId: string) => {
-    setLocallyOpenedNotifIds((prev) => new Set(prev).add(notificationId));
+    notificationResponse.mutate(
+      (pages) =>
+        pages?.map((page) => ({
+          ...page,
+          docs: page.docs.map((doc) =>
+            doc._id.toString() === notificationId
+              ? { ...doc, status: InAppNotificationStatuses.STATUS_OPENED }
+              : doc,
+          ),
+        })),
+      { revalidate: false },
+    );
   };
 
   if (activeFilter === 'news') {
@@ -284,14 +286,7 @@ export const InAppNotificationContent = (
               return (
                 <InAppNotificationElm
                   key={id}
-                  notification={
-                    locallyOpenedNotifIds.has(id)
-                      ? {
-                          ...notification,
-                          status: InAppNotificationStatuses.STATUS_OPENED,
-                        }
-                      : notification
-                  }
+                  notification={notification}
                   onUnopenedNotificationOpend={() => handleNotificationRead(id)}
                 />
               );
@@ -314,11 +309,7 @@ export const InAppNotificationContent = (
   return (
     <div className={scrollAreaClassName} style={scrollAreaStyle}>
       <InfiniteScroll
-        swrInifiniteResponse={
-          allModeSWRResponse as unknown as Parameters<
-            typeof InfiniteScroll
-          >[0]['swrInifiniteResponse']
-        }
+        swrInifiniteResponse={allModeSWRResponse}
         isReachingEnd={newsExhausted && notifExhausted}
       >
         <div className="list-group">
@@ -336,14 +327,7 @@ export const InAppNotificationContent = (
             return (
               <InAppNotificationElm
                 key={`notif-${id}`}
-                notification={
-                  locallyOpenedNotifIds.has(id)
-                    ? {
-                        ...entry.item,
-                        status: InAppNotificationStatuses.STATUS_OPENED,
-                      }
-                    : entry.item
-                }
+                notification={entry.item}
                 onUnopenedNotificationOpend={() => handleNotificationRead(id)}
               />
             );

+ 18 - 32
apps/app/src/features/news/client/components/NewsItem.spec.tsx

@@ -4,7 +4,8 @@ import mongoose from 'mongoose';
 const mocks = vi.hoisted(() => {
   const apiv3Post = vi.fn().mockResolvedValue({});
   const mutate = vi.fn();
-  return { apiv3Post, mutate };
+  const i18nLanguage = { current: 'ja_JP' };
+  return { apiv3Post, mutate, i18nLanguage };
 });
 
 vi.mock('~/client/util/apiv3-client', () => ({
@@ -14,7 +15,11 @@ vi.mock('~/client/util/apiv3-client', () => ({
 vi.mock('next-i18next', () => ({
   useTranslation: () => ({
     t: (key: string) => key,
-    i18n: { language: 'ja_JP' },
+    i18n: {
+      get language() {
+        return mocks.i18nLanguage.current;
+      },
+    },
   }),
 }));
 
@@ -42,6 +47,7 @@ describe('NewsItem', () => {
 
   beforeEach(() => {
     vi.clearAllMocks();
+    mocks.i18nLanguage.current = 'ja_JP';
   });
 
   describe('emoji display', () => {
@@ -59,55 +65,35 @@ describe('NewsItem', () => {
   });
 
   describe('locale fallback', () => {
-    test('should display ja_JP title when browser language is ja_JP', () => {
+    test('should display ja_JP title when i18n language is ja_JP', () => {
+      mocks.i18nLanguage.current = 'ja_JP';
       const item = makeNewsItem({
         title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
       });
-      render(
-        <NewsItem
-          item={item}
-          onReadMutate={onReadMutate}
-          browserLanguage="ja_JP"
-        />,
-      );
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
       expect(screen.getByText('日本語タイトル')).toBeTruthy();
     });
 
-    test('should fallback to ja_JP when browser language has no match', () => {
+    test('should fallback to ja_JP when i18n language has no match', () => {
+      mocks.i18nLanguage.current = 'de_DE';
       const item = makeNewsItem({
         title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
       });
-      render(
-        <NewsItem
-          item={item}
-          onReadMutate={onReadMutate}
-          browserLanguage="de_DE"
-        />,
-      );
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
       expect(screen.getByText('日本語タイトル')).toBeTruthy();
     });
 
     test('should fallback to en_US when ja_JP is not available', () => {
+      mocks.i18nLanguage.current = 'de_DE';
       const item = makeNewsItem({ title: { en_US: 'English Only' } });
-      render(
-        <NewsItem
-          item={item}
-          onReadMutate={onReadMutate}
-          browserLanguage="de_DE"
-        />,
-      );
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
       expect(screen.getByText('English Only')).toBeTruthy();
     });
 
     test('should fallback to first available key when neither ja_JP nor en_US', () => {
+      mocks.i18nLanguage.current = 'de_DE';
       const item = makeNewsItem({ title: { fr_FR: 'Titre Français' } });
-      render(
-        <NewsItem
-          item={item}
-          onReadMutate={onReadMutate}
-          browserLanguage="de_DE"
-        />,
-      );
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
       expect(screen.getByText('Titre Français')).toBeTruthy();
     });
   });

+ 19 - 33
apps/app/src/features/news/client/components/NewsItem.tsx

@@ -1,6 +1,10 @@
 import type { FC } from 'react';
+import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
 
+import unreadDotStyles from '~/client/components/InAppNotification/UnreadDot.module.scss';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { getLocale } from '~/server/util/locale-utils';
 
 import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
 
@@ -24,22 +28,22 @@ const resolveTitle = (
 type Props = {
   item: INewsItemWithReadStatus;
   onReadMutate: () => void;
-  browserLanguage?: string;
 };
 
-export const NewsItem: FC<Props> = ({
-  item,
-  onReadMutate,
-  browserLanguage,
-}) => {
-  const locale =
-    browserLanguage ??
-    (typeof navigator !== 'undefined'
-      ? navigator.language.replace('-', '_')
-      : 'ja_JP');
+export const NewsItem: FC<Props> = ({ item, onReadMutate }) => {
+  const { i18n } = useTranslation();
+  const locale = i18n.language;
   const title = resolveTitle(item.title, locale);
   const emoji = item.emoji ?? DEFAULT_EMOJI;
 
+  const publishedDate =
+    item.publishedAt instanceof Date
+      ? item.publishedAt
+      : new Date(item.publishedAt);
+  const formattedDate = format(publishedDate, 'PP', {
+    locale: getLocale(locale),
+  });
+
   const handleClick = async () => {
     try {
       await apiv3Post('/news/mark-read', { newsItemId: item._id.toString() });
@@ -56,37 +60,19 @@ export const NewsItem: FC<Props> = ({
   return (
     <button
       type="button"
-      className="list-group-item list-group-item-action"
-      style={{
-        cursor: 'pointer',
-        width: '100%',
-        textAlign: 'left',
-        background: 'none',
-      }}
+      className="list-group-item list-group-item-action w-100 text-start bg-transparent"
       onClick={handleClick}
     >
       <div className="d-flex align-items-center">
-        {/* Unread indicator dot or transparent spacer */}
         <span
-          className={`${item.isRead ? '' : 'bg-primary'} rounded-circle me-3`}
-          style={{ width: 8, height: 8, minWidth: 8, display: 'inline-block' }}
+          className={`${item.isRead ? '' : 'bg-primary'} rounded-circle me-3 ${unreadDotStyles['unread-dot']}`}
         />
 
-        {/* Avatar position: emoji */}
-        <span className="me-2" style={{ fontSize: '1.2rem', lineHeight: 1 }}>
-          {emoji}
-        </span>
+        <span className="me-2 fs-5 lh-1">{emoji}</span>
 
-        {/* Content column */}
         <div>
           <span className={item.isRead ? 'fw-normal' : 'fw-bold'}>{title}</span>
-          <div className="text-muted small">
-            {item.publishedAt instanceof Date
-              ? item.publishedAt.toLocaleDateString(locale.replace('_', '-'))
-              : new Date(item.publishedAt).toLocaleDateString(
-                  locale.replace('_', '-'),
-                )}
-          </div>
+          <div className="text-muted small">{formattedDate}</div>
         </div>
       </div>
     </button>

+ 4 - 1
apps/app/src/interfaces/in-app-notification.ts

@@ -1,4 +1,4 @@
-import type { IUser } from '@growi/core';
+import type { HasObjectId, IUser } from '@growi/core';
 
 import type { SupportedActionType, SupportedTargetModelType } from './activity';
 
@@ -19,6 +19,9 @@ export interface IInAppNotification<T = unknown> {
   parsedSnapshot?: any;
 }
 
+export type IInAppNotificationHasId<T = unknown> = IInAppNotification<T> &
+  HasObjectId;
+
 /*
  * Note:
  * Need to use mongoose PaginateResult as a type after upgrading mongoose v6.0.0.

+ 22 - 13
apps/app/src/stores/in-app-notification.ts

@@ -5,7 +5,7 @@ import useSWRInfinite from 'swr/infinite';
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type {
-  IInAppNotification,
+  IInAppNotificationHasId,
   InAppNotificationStatuses,
   PaginateResult,
 } from '~/interfaces/in-app-notification';
@@ -16,21 +16,24 @@ import { apiv3Get } from '../client/util/apiv3-client';
 
 const logger = loggerFactory('growi:cli:InAppNotification');
 
-type inAppNotificationPaginateResult = PaginateResult<IInAppNotification>;
+type InAppNotificationPaginateResult = PaginateResult<IInAppNotificationHasId>;
 
 export const useSWRxInAppNotifications = (
   limit: number,
   offset?: number,
   status?: InAppNotificationStatuses,
   config?: SWRConfiguration,
-): SWRResponse<PaginateResult<IInAppNotification>, Error> => {
+): SWRResponse<InAppNotificationPaginateResult, Error> => {
   return useSWR(
     ['/in-app-notification/list', limit, offset, status],
     ([endpoint]) =>
-      apiv3Get(endpoint, { limit, offset, status }).then((response) => {
-        const inAppNotificationPaginateResult =
-          response.data as inAppNotificationPaginateResult;
-        inAppNotificationPaginateResult.docs.forEach((doc) => {
+      apiv3Get<InAppNotificationPaginateResult>(endpoint, {
+        limit,
+        offset,
+        status,
+      }).then((response) => {
+        const result = response.data;
+        result.docs.forEach((doc) => {
           try {
             if (doc.targetModel === SupportedTargetModel.MODEL_USER) {
               doc.parsedSnapshot = userSerializers.parseSnapshot(doc.snapshot);
@@ -39,7 +42,7 @@ export const useSWRxInAppNotifications = (
             logger.warn('Failed to parse snapshot', err);
           }
         });
-        return inAppNotificationPaginateResult;
+        return result;
       }),
     config,
   );
@@ -50,7 +53,9 @@ export const useSWRxInAppNotificationStatus = (): SWRResponse<
   Error
 > => {
   return useSWR('/in-app-notification/status', (endpoint) =>
-    apiv3Get(endpoint).then((response) => response.data.count),
+    apiv3Get<{ count: number }>(endpoint).then(
+      (response) => response.data.count,
+    ),
   );
 };
 
@@ -65,10 +70,10 @@ export const useSWRINFxInAppNotifications = (
   limit: number,
   options?: { status?: InAppNotificationStatuses },
   config?: SWRConfiguration,
-): SWRInfiniteResponse<PaginateResult<IInAppNotification>, Error> => {
+): SWRInfiniteResponse<InAppNotificationPaginateResult, Error> => {
   const status = options?.status;
 
-  return useSWRInfinite<PaginateResult<IInAppNotification>, Error>(
+  return useSWRInfinite<InAppNotificationPaginateResult, Error>(
     (pageIndex, previousPageData): InAppNotificationListKey => {
       if (previousPageData != null && !previousPageData.hasNextPage)
         return null;
@@ -76,8 +81,12 @@ export const useSWRINFxInAppNotifications = (
       return ['/in-app-notification/list', limit, offset, status];
     },
     ([endpoint, limit, offset, status]) =>
-      apiv3Get(endpoint, { limit, offset, status }).then((response) => {
-        const result = response.data as inAppNotificationPaginateResult;
+      apiv3Get<InAppNotificationPaginateResult>(endpoint, {
+        limit,
+        offset,
+        status,
+      }).then((response) => {
+        const result = response.data;
         result.docs.forEach((doc) => {
           try {
             if (doc.targetModel === SupportedTargetModel.MODEL_USER) {