Yuki Takei 6 месяцев назад
Родитель
Сommit
5524796fc0

+ 80 - 7
.serena/memories/apps-app-jotai-migration-progress.md

@@ -104,10 +104,37 @@ export const use[Modal]Actions = (): [Modal]Actions => {
 };
 ```
 
+#### デバイス状態パターン(Jotaiベース)
+```typescript
+// 例: useDeviceLargerThanMd
+export const isDeviceLargerThanMdAtom = atom(false);
+
+export const useDeviceLargerThanMd = () => {
+  const [isLargerThanMd, setIsLargerThanMd] = useAtom(isDeviceLargerThanMdAtom);
+
+  useEffect(() => {
+    if (isClient()) {
+      const mdOrAboveHandler = function (this: MediaQueryList): void {
+        setIsLargerThanMd(this.matches);
+      };
+      const mql = addBreakpointListener(Breakpoint.MD, mdOrAboveHandler);
+      setIsLargerThanMd(mql.matches); // initialize
+      return () => {
+        cleanupBreakpointListener(mql, mdOrAboveHandler);
+      };
+    }
+    return undefined;
+  }, [setIsLargerThanMd]);
+
+  return [isLargerThanMd, setIsLargerThanMd] as const;
+};
+```
+
 #### 使用パターン
 - **ステータスのみ必要**: `use[Modal]Status()`
 - **アクションのみ必要**: `use[Modal]Actions()`
 - **両方必要**: 2つのフックを併用
+- **デバイス状態**: `const [isLargerThanMd] = useDeviceLargerThanMd()`
 
 #### 重要事項
 - **後方互換フックは不要**: 移行完了後は即座に削除
@@ -118,7 +145,7 @@ export const use[Modal]Actions = (): [Modal]Actions => {
 
 ### UI関連状態(完了)
 - ✅ **サイドバー状態**: `useDrawerOpened`, `useSetPreferCollapsedMode`, `useSidebarMode`, `useCurrentSidebarContents`, `useCollapsedContentsOpened`, `useCurrentProductNavWidth`
-- ✅ **デバイス状態**: `useDeviceLargerThanXl`
+- ✅ **デバイス状態**: `useDeviceLargerThanXl`, `useDeviceLargerThanLg`, `useDeviceLargerThanMd`, `useIsMobile` (2025-09-11完了)
 - ✅ **エディター状態**: `useEditorMode`, `useSelectedGrant`
 - ✅ **ページUI状態**: `usePageControlsX`
 
@@ -185,6 +212,33 @@ export const use[Modal]Actions = (): [Modal]Actions => {
 - **品質確認**: 型チェック成功、全使用箇所移行済み
 - **統一された実装**: 全17個のモーダルで一貫したパターン
 
+### 🆕 デバイス状態移行完了(2025-09-11完了)
+
+#### ✅ Phase 1: デバイス幅関連フック3個一括移行完了
+- ✅ **`useIsDeviceLargerThanMd`**: MD以上のデバイス幅判定
+  - 使用箇所:8個のコンポーネント完全移行
+- ✅ **`useIsDeviceLargerThanLg`**: LG以上のデバイス幅判定
+  - 使用箇所:3個のコンポーネント完全移行
+- ✅ **`useIsMobile`**: モバイルデバイス判定
+  - 使用箇所:1個のコンポーネント完全移行
+
+#### 🚀 移行の成果
+- **統一パターン**: 既存の `useDeviceLargerThanXl` パターンに合わせて実装
+- **MediaQuery対応**: ブレークポイント監視による動的な状態更新
+- **モバイル検出**: タッチスクリーン・UserAgent による高精度判定
+- **テスト修正**: モックファイルの更新完了
+- **旧コード削除**: `stores/ui.tsx` から3つのフック削除完了
+
+#### 📊 移行詳細
+**移行されたファイル数**: 11個
+- PageControls.tsx, AccessTokenScopeList.tsx, PageEditorModeManager.tsx
+- GrowiContextualSubNavigation.tsx, SavePageControls.tsx, OptionsSelector.tsx
+- Sidebar.tsx, PageListItemL.tsx, DescendantsPageListModal.tsx
+- PageAccessoriesModal.tsx, PrimaryItem.tsx
+
+**テストファイル修正**: 1個
+- DescendantsPageListModal.spec.tsx: モック戻り値を `{ data: boolean }` → `[boolean]` に変更
+
 ## ✅ プロジェクト完了ステータス
 
 ### 🎯 モーダル移行プロジェクト: **100% 完了** ✅
@@ -195,32 +249,51 @@ export const use[Modal]Actions = (): [Modal]Actions => {
 - 🏆 **保守性**: 統一されたディレクトリ構造と実装パターン
 - 🏆 **互換性**: 全使用箇所の移行完了、旧実装の完全削除
 
+### 🎯 デバイス状態移行: **Phase 1 完了** ✅
+
+**主要デバイス判定フック4個**がJotaiベースに移行完了:
+- 🏆 **統一パターン**: `useAtom` + `useEffect` でのBreakpoint監視
+- 🏆 **動的更新**: MediaQuery変更時の自動状態更新
+- 🏆 **高精度判定**: モバイル検出の複数手法組み合わせ
+- 🏆 **完全移行**: 全使用箇所(11ファイル)の移行完了
+
 ### 🚀 成果とメリット
 1. **パフォーマンス向上**: 不要なリレンダリングの削減
 2. **開発体験向上**: 統一されたAPIパターン
 3. **保守性向上**: 個別ファイル化による責務明確化
 4. **型安全性**: Jotaiによる強固な型システム
+5. **レスポンシブ対応**: 正確なデバイス幅・モバイル判定
 
 ### 📊 最終進捗サマリー
-- **完了**: 主要なUI状態 + ページ関連状態 + SSRハイドレーション + **全17個のモーダル**
+- **完了**: 主要なUI状態 + ページ関連状態 + SSRハイドレーション + **全17個のモーダル** + **デバイス状態4個**
 - **モーダル移行**: **100% 完了** (17/17個)
+- **デバイス状態移行**: **Phase 1完了** (4/4個)
 - **品質保証**: 全型チェック成功、パフォーマンス最適化済み
 - **ドキュメント**: 完全な実装パターンガイド確立
 
 ## 🔮 今後の発展可能性
 
-### 次のフェーズ候補
-1. **AI機能のモーダル**: OpenAI関連のモーダル状態の統合検討
-2. **エディタパッケージ統合**: `@growi/editor`内のモーダル状態の統合
-3. **UI関連フックの最適化**: 残存するSWRベースフックの選択的移行
+### 次のフェーズ候補(Phase 2)
+1. **残存SWRフック**: `stores/ui.tsx` 内の残り4個のフック
+   - `useCurrentPageTocNode` - ページ目次ノード
+   - `useSidebarScrollerRef` - サイドバースクローラー参照  
+   - `usePageTreeDescCountMap` - ページツリー子孫数マップ
+   - `useCommentEditorDirtyMap` - コメントエディター変更状態
+2. **AI機能のモーダル**: OpenAI関連のモーダル状態の統合検討
+3. **エディタパッケージ統合**: `@growi/editor`内のモーダル状態の統合
 
 ### クリーンアップ候補
 - `stores/modal.tsx` 完全削除(既に空ファイル化済み)
-- `stores/ui.tsx` の段階的縮小検討
+- `stores/ui.tsx` の段階的縮小検討(4個のフック残存)
 - 未使用SWRフックの調査・クリーンアップ
 
 ## 🔄 更新履歴
 
+- **2025-09-11**: 🎉 **Phase 1完了 - デバイス状態移行100%完了!**
+  - useIsDeviceLargerThanMd, useIsDeviceLargerThanLg, useIsMobile移行完了
+  - 11個のコンポーネント全使用箇所移行、テストファイル修正
+  - `states/ui/device.ts`に4個のデバイス関連フック統一
+  - 旧コード削除、不要インポート削除完了
 - **2025-09-05**: 🎉 **第5バッチ完了 - モーダル移行プロジェクト100%完了!**
   - PageBulkExportSelect, DrawioForEditor, LinkEdit, Template移行完了
   - 全17個のモーダルがJotaiベースに統一

+ 4 - 4
apps/app/src/client/components/DescendantsPageListModal.spec.tsx

@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
 const mockClose = vi.hoisted(() => vi.fn());
-const useIsDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+const useDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue([true]));
 
 vi.mock('next/router', () => ({
   useRouter: () => ({
@@ -21,8 +21,8 @@ vi.mock('~/stores/modal', () => ({
   }),
 }));
 
-vi.mock('~/stores/ui', () => ({
-  useIsDeviceLargerThanLg,
+vi.mock('~/states/ui/device', () => ({
+  useDeviceLargerThanLg,
 }));
 
 describe('DescendantsPageListModal.tsx', () => {
@@ -54,7 +54,7 @@ describe('DescendantsPageListModal.tsx', () => {
 
   describe('when device is smaller than lg', () => {
     beforeEach(() => {
-      useIsDeviceLargerThanLg.mockReturnValue({ data: false });
+      useDeviceLargerThanLg.mockReturnValue([false]);
     });
 
     it('should render CustomNavDropdown on devices smaller than lg', () => {

+ 2 - 2
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -11,8 +11,8 @@ import {
 } from 'reactstrap';
 
 import { useIsSharedUser } from '~/states/context';
+import { useDeviceLargerThanLg } from '~/states/ui/device';
 import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
-import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
 import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
@@ -38,7 +38,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
 
   const { events } = useRouter();
 
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
+  const [isDeviceLargerThanLg] = useDeviceLargerThanLg();
 
   useEffect(() => {
     events.on('routeChangeStart', close);

+ 2 - 2
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -4,7 +4,7 @@ import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
-import { useIsDeviceLargerThanMd } from '~/stores/ui';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 
 
 import styles from './AccessTokenScopeList.module.scss';
@@ -32,7 +32,7 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
   level = 1,
 }) => {
 
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   // Convert object into an array to determine "first vs. non-first" elements
   const entries = Object.entries(scopeObject);

+ 10 - 10
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -35,6 +35,7 @@ import {
   isLocalAccountRegistrationEnabledAtom,
   isUploadEnabledAtom,
 } from '~/states/server-configurations';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode } from '~/states/ui/editor';
 import { PageAccessoriesModalContents, usePageAccessoriesModalActions } from '~/states/ui/modal/page-accessories';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
@@ -48,7 +49,6 @@ import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 import {
   useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
-  useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 import { NotAvailable } from '../NotAvailable';
@@ -97,7 +97,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 
   const [isBulkExportTooltipOpen, setIsBulkExportTooltipOpen] = useState(false);
 
-  const syncLatestRevisionBodyHandler = useCallback(async() => {
+  const syncLatestRevisionBodyHandler = useCallback(async () => {
     // eslint-disable-next-line no-alert
     const answer = window.confirm(t('sync-latest-revision-body.confirm'));
     if (answer) {
@@ -281,7 +281,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   const { open: openDuplicateModal } = usePageDuplicateModalActions();
   const { open: openRenameModal } = usePageRenameModalActions();
@@ -297,14 +297,14 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
-  const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
+  const duplicateItemClickedHandler = useCallback(async (page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);
     };
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal, router]);
 
-  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
+  const renameItemClickedHandler = useCallback(async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
       fetchCurrentPage();
       mutatePageInfo();
@@ -338,7 +338,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
   }, [currentPathname, fetchCurrentPage, openDeleteModal, router, mutatePageInfo]);
 
-  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
+  const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
     if (!isSharedPage) {
       await updateContentWidth(pageId, value);
       fetchCurrentPage();
@@ -426,12 +426,12 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                 editorMode={editorMode}
                 isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 path={path}
-                // grant={grant}
-                // grantUserGroupId={grantUserGroupId}
+              // grant={grant}
+              // grantUserGroupId={grantUserGroupId}
               />
             )}
 
-            { isGuestUser && (
+            {isGuestUser && (
               <div className="mt-2">
                 <span>
                   <span className="d-inline-block" id="sign-up-link">
@@ -454,7 +454,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                   <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
                 </Link>
               </div>
-            ) }
+            )}
           </nav>
 
         </GroundGlassBar>

+ 4 - 4
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -9,8 +9,8 @@ import { useTranslation } from 'next-i18next';
 import { useCreatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { usePageNotFound } from '~/states/page';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode, EditorMode } from '~/states/ui/editor';
-import { useIsDeviceLargerThanMd } from '~/stores/ui';
 import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
@@ -68,12 +68,12 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const isNotFound = usePageNotFound();
   const { setEditorMode } = useEditorMode();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const { isCreating, create } = useCreatePage();
 
-  const editButtonClickedHandler = useCallback(async() => {
+  const editButtonClickedHandler = useCallback(async () => {
     if (isNotFound == null || isNotFound === false) {
       setEditorMode(EditorMode.Editor);
       return;
@@ -131,7 +131,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
-            { circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
+            {circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
           </PageEditorModeButton>
         )}
       </div>

+ 2 - 2
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -9,8 +9,8 @@ import {
 
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { disableLinkSharingAtom } from '~/states/server-configurations';
+import { useDeviceLargerThanLg } from '~/states/ui/device';
 import { usePageAccessoriesModalStatus, usePageAccessoriesModalActions, PageAccessoriesModalContents } from '~/states/ui/modal/page-accessories';
-import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
 import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -36,7 +36,7 @@ export const PageAccessoriesModal = (): JSX.Element => {
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isLinkSharingDisabled = useAtomValue(disableLinkSharingAtom);
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
+  const [isDeviceLargerThanLg] = useDeviceLargerThanLg();
 
   const status = usePageAccessoriesModalStatus();
   const { close, selectContents } = usePageAccessoriesModalActions();

+ 14 - 14
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -22,13 +22,13 @@ import OpenDefaultAiAssistantButton from '~/features/openai/client/components/Ai
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/states/context';
 import { useCurrentPagePath } from '~/states/page';
 import { isUsersHomepageDeletionEnabledAtom } from '~/states/server-configurations';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 import {
   EditorMode, useEditorMode,
 } from '~/states/ui/editor';
 import { type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 import { useTagEditModalActions } from '~/states/ui/modal/tag-edit';
 import { useSetPageControlsX } from '~/states/ui/page';
-import { useIsDeviceLargerThanMd } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../../stores/page';
@@ -92,10 +92,10 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
           className="form-check-input pe-none"
           type="checkbox"
           checked={expandContentWidth}
-          onChange={() => {}}
+          onChange={() => { }}
         />
         <label className="form-check-label pe-none">
-          { t('wide_view') }
+          {t('wide_view')}
         </label>
       </div>
     </DropdownItem>
@@ -136,7 +136,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const { editorMode } = useEditorMode();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const isSearchPage = useIsSearchPage();
   const isUsersHomepageDeletionEnabled = useAtomValue(isUsersHomepageDeletionEnabledAtom);
   const currentPagePath = useCurrentPagePath();
@@ -166,7 +166,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
-  const subscribeClickhandler = useCallback(async() => {
+  const subscribeClickhandler = useCallback(async () => {
     if (isGuestUser ?? true) {
       return;
     }
@@ -178,7 +178,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
-  const likeClickhandler = useCallback(async() => {
+  const likeClickhandler = useCallback(async () => {
     if (isGuestUser ?? true) {
       return;
     }
@@ -190,7 +190,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
-  const duplicateMenuItemClickHandler = useCallback(async(): Promise<void> => {
+  const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickDuplicateMenuItem == null || path == null) {
       return;
     }
@@ -199,7 +199,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     onClickDuplicateMenuItem(page);
   }, [onClickDuplicateMenuItem, pageId, path]);
 
-  const renameMenuItemClickHandler = useCallback(async(): Promise<void> => {
+  const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickRenameMenuItem == null || path == null) {
       return;
     }
@@ -216,7 +216,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     onClickRenameMenuItem(page);
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
-  const deleteMenuItemClickHandler = useCallback(async(): Promise<void> => {
+  const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickDeleteMenuItem == null || path == null) {
       return;
     }
@@ -306,7 +306,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   return (
     <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
-      { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
+      {isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
           <SearchButton />
           <OpenDefaultAiAssistantButton />
@@ -319,7 +319,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         />
       )}
 
-      { !hideSubControls && (
+      {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
           {revisionId != null && _isIPageInfoForOperation && (
             <SubscribeButton
@@ -348,11 +348,11 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
               sumOfSeenUsers={sumOfSeenUsers}
               disabled={disableSeenUserInfoPopover}
             />
-          ) }
+          )}
         </div>
-      ) }
+      )}
 
-      { showPageControlDropdown && _isIPageInfoForOperation && (
+      {showPageControlDropdown && _isIPageInfoForOperation && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}

+ 19 - 19
apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx

@@ -13,9 +13,9 @@ import {
 } from 'reactstrap';
 
 import { isIndentSizeForcedAtom } from '~/states/server-configurations';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 import {
-  useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 type RadioListItemProps = {
@@ -63,7 +63,7 @@ const Selector = (props: SelectorProps): JSX.Element => {
       </button>
       <hr className="my-1" />
       <ul className="list-group d-flex ms-2">
-        { items }
+        {items}
       </ul>
     </div>
   );
@@ -88,7 +88,7 @@ const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = {
   kimbie: 'Kimbie',
 };
 
-const ThemeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
+const ThemeSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: editorSettings, update } = useEditorSettings();
@@ -96,12 +96,12 @@ const ThemeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX
 
   const listItems = useMemo(() => (
     <>
-      { (Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map((theme) => {
+      {(Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map((theme) => {
         const themeLabel = EDITORTHEME_LABEL_MAP[theme];
         return (
           <RadioListItem onClick={() => update({ theme })} text={themeLabel} checked={theme === selectedTheme} />
         );
-      }) }
+      })}
     </>
   ), [update, selectedTheme]);
 
@@ -123,7 +123,7 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
   vscode: 'Visual Studio Code',
 };
 
-const KeymapSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
+const KeymapSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: editorSettings, update } = useEditorSettings();
@@ -131,7 +131,7 @@ const KeymapSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JS
 
   const listItems = useMemo(() => (
     <>
-      { (Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
+      {(Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
         const keymapLabel = KEYMAP_LABEL_MAP[keymapMode];
         const icon = (keymapMode !== 'default')
           ? <Image src={`/images/icons/${keymapMode}.png`} width={16} height={16} className="me-2" alt={keymapMode} />
@@ -139,7 +139,7 @@ const KeymapSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JS
         return (
           <RadioListItem onClick={() => update({ keymapMode })} icon={icon} text={keymapLabel} checked={keymapMode === selectedKeymapMode} />
         );
-      }) }
+      })}
     </>
   ), [update, selectedKeymapMode]);
 
@@ -153,18 +153,18 @@ KeymapSelector.displayName = 'KeymapSelector';
 
 const TYPICAL_INDENT_SIZE = [2, 4];
 
-const IndentSizeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
+const IndentSizeSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
 
   const listItems = useMemo(() => (
     <>
-      { TYPICAL_INDENT_SIZE.map((indent) => {
+      {TYPICAL_INDENT_SIZE.map((indent) => {
         return (
           <RadioListItem onClick={() => mutateCurrentIndentSize(indent)} text={indent.toString()} checked={indent === currentIndentSize} />
         );
-      }) }
+      })}
     </>
   ), [currentIndentSize, mutateCurrentIndentSize]);
 
@@ -175,7 +175,7 @@ const IndentSizeSelector = memo(({ onClickBefore }: {onClickBefore: () => void})
 IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
-const PasteSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
+const PasteSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: editorSettings, update } = useEditorSettings();
@@ -183,11 +183,11 @@ const PasteSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX
 
   const listItems = useMemo(() => (
     <>
-      { (AllPasteMode).map((pasteMode) => {
+      {(AllPasteMode).map((pasteMode) => {
         return (
           <RadioListItem onClick={() => update({ pasteMode })} text={t(`page_edit.paste.${pasteMode}`) ?? ''} checked={pasteMode === selectedPasteMode} />
         );
-      }) }
+      })}
     </>
   ), [update, t, selectedPasteMode]);
 
@@ -307,7 +307,7 @@ export const OptionsSelector = (): JSX.Element => {
   const { data: editorSettings } = useEditorSettings();
   const { data: currentIndentSize } = useCurrentIndentSize();
   const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom);
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) {
     return <></>;
@@ -365,19 +365,19 @@ export const OptionsSelector = (): JSX.Element => {
             </div>
           )
         }
-        { status === OptionsStatus.Theme && (
+        {status === OptionsStatus.Theme && (
           <ThemeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
         )
         }
-        { status === OptionsStatus.Keymap && (
+        {status === OptionsStatus.Keymap && (
           <KeymapSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
         )
         }
-        { status === OptionsStatus.Indent && (
+        {status === OptionsStatus.Indent && (
           <IndentSizeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
         )
         }
-        { status === OptionsStatus.Paste && (
+        {status === OptionsStatus.Paste && (
           <PasteSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
         )}
       </DropdownMenu>

+ 4 - 4
apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -19,9 +19,9 @@ import {
   isAclEnabledAtom,
   isSlackConfiguredAtom,
 } from '~/states/server-configurations';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode, useSelectedGrant } from '~/states/ui/editor';
 import { useWaitingSaveProcessing, useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useIsDeviceLargerThanMd } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { NotAvailable } from '../../NotAvailable';
@@ -39,7 +39,7 @@ declare global {
 const logger = loggerFactory('growi:SavePageControls');
 
 
-const SavePageButton = (props: {slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean}) => {
+const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean }) => {
 
   const { t } = useTranslation();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
@@ -50,7 +50,7 @@ const SavePageButton = (props: {slackChannels: string, isSlackEnabled?: boolean,
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
-  const save = useCallback(async(): Promise<void> => {
+  const save = useCallback(async (): Promise<void> => {
     // save
     globalEmitter.emit('saveAndReturnToView', { wip: false, slackChannels, isSlackEnabled });
   }, [isSlackEnabled, slackChannels]);
@@ -155,7 +155,7 @@ export const SavePageControls = (): JSX.Element | null => {
   const isSlackConfigured = useAtomValue(isSlackConfiguredAtom);
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   const [slackChannels, setSlackChannels] = useState<string>('');
   const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);

+ 5 - 5
apps/app/src/client/components/PageList/PageListItemL.tsx

@@ -25,12 +25,12 @@ import type {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
+import { useDeviceLargerThanLg } from '~/states/ui/device';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
 import { usePageRenameModalActions } from '~/states/ui/modal/page-rename';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
-import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../../stores/page';
@@ -85,7 +85,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     },
   }));
 
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
+  const [isDeviceLargerThanLg] = useDeviceLargerThanLg();
   const { open: openDuplicateModal } = usePageDuplicateModalActions();
   const { open: openRenameModal } = usePageRenameModalActions();
   const { open: openDeleteModal } = usePageDeleteModalActions();
@@ -128,7 +128,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     }
   }, [isDeviceLargerThanLg, onClickItem, pageData._id]);
 
-  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+  const bookmarkMenuItemClickHandler = async (_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
     mutateCurrentUserBookmarks();
@@ -156,10 +156,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
   }, [pageData, openDeleteModal, onPageDeleted]);
 
-  const revertMenuItemClickHandler = useCallback(async() => {
+  const revertMenuItemClickHandler = useCallback(async () => {
     const { _id: pageId, path } = pageData;
 
-    const putBackedHandler = async(path) => {
+    const putBackedHandler = async (path) => {
       try {
         // pageData path should be `/trash/fuga` (`/trash` should be included to the prefix)
         await unlink(pageData.path);

+ 8 - 9
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -9,7 +9,7 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsSearchPage } from '~/states/context';
-import { useDeviceLargerThanXl } from '~/states/ui/device';
+import { useDeviceLargerThanXl, useDeviceLargerThanMd } from '~/states/ui/device';
 import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import {
   useDrawerOpened,
@@ -20,7 +20,6 @@ import {
 } from '~/states/ui/sidebar';
 import {
   useSidebarScrollerRef,
-  useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 import { DrawerToggler } from '../Common/DrawerToggler';
@@ -78,7 +77,7 @@ const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element =>
   const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
 
   const [isClient, setClient] = useState(false);
-  const [resizableAreaWidth, setResizableAreaWidth] = useState<number|undefined>(
+  const [resizableAreaWidth, setResizableAreaWidth] = useState<number | undefined>(
     getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth),
   );
 
@@ -217,9 +216,9 @@ const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
       <div {...divProps} className={`${className} ${openClass}`}>
         {children}
       </div>
-      { isDrawerOpened && (
+      {isDrawerOpened && (
         <div className="modal-backdrop fade show" onClick={() => setIsDrawerOpened(false)} />
-      ) }
+      )}
     </>
   );
 });
@@ -234,7 +233,7 @@ export const Sidebar = (): JSX.Element => {
 
   const isSearchPage = useIsSearchPage();
   const { editorMode } = useEditorMode();
-  const { data: isMdSize } = useIsDeviceLargerThanMd();
+  const [isMdSize] = useDeviceLargerThanMd();
   const [isXlSize] = useDeviceLargerThanXl();
 
   const isEditorMode = editorMode === EditorMode.Editor;
@@ -260,17 +259,17 @@ export const Sidebar = (): JSX.Element => {
 
   return (
     <>
-      { sidebarMode != null && isDrawerMode() && (
+      {sidebarMode != null && isDrawerMode() && (
         <DrawerToggler className="position-fixed d-none d-md-block">
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
       )}
-      { sidebarMode != null && !isDockMode() && !isSearchPage && !shouldHideSubnavAppTitle && (
+      {sidebarMode != null && !isDockMode() && !isSearchPage && !shouldHideSubnavAppTitle && (
         <AppTitleOnSubnavigation />
       )}
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
-          { sidebarMode != null && !isCollapsedMode() && (
+          {sidebarMode != null && !isCollapsedMode() && (
             <AppTitleOnSidebarHead hideAppTitle={shouldHideSiteName} />
           )}
           {shouldShowEditorSidebarHead ? <AppTitleOnEditorSidebarHead /> : <SidebarHead />}

+ 4 - 4
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx

@@ -5,8 +5,8 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import type { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarMode } from '~/interfaces/ui';
+import { useIsMobile } from '~/states/ui/device';
 import { useCollapsedContentsOpened, useCurrentSidebarContents } from '~/states/ui/sidebar';
-import { useIsMobile } from '~/stores/ui';
 
 const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
   const [isCollapsedContentsOpened] = useCollapsedContentsOpened();
@@ -38,7 +38,7 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
   const [currentContents, setCurrentContents] = useCurrentSidebarContents();
 
   const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
-  const { data: isMobile } = useIsMobile();
+  const [isMobile] = useIsMobile();
   const { t } = useTranslation();
 
   const selectThisItem = useCallback(() => {
@@ -79,10 +79,10 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
         id={labelForTestId}
       >
         <div className="position-relative">
-          { badgeContents != null && (
+          {badgeContents != null && (
             <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
           )}
-          { isCustomIcon
+          {isCustomIcon
             ? (<span className="growi-custom-icons fs-4 align-middle">{iconName}</span>)
             : (<span className="material-symbols-outlined">{iconName}</span>)
           }

+ 88 - 17
apps/app/src/states/ui/device.ts

@@ -1,4 +1,3 @@
-import { isClient } from '@growi/core/dist/utils';
 import { Breakpoint } from '@growi/ui/dist/interfaces';
 import {
   addBreakpointListener,
@@ -9,28 +8,100 @@ import { useEffect } from 'react';
 
 // Device state atoms
 export const isDeviceLargerThanXlAtom = atom(false);
+export const isDeviceLargerThanLgAtom = atom(false);
+export const isDeviceLargerThanMdAtom = atom(false);
+export const isMobileAtom = atom(false);
 
 export const useDeviceLargerThanXl = () => {
   const [isLargerThanXl, setIsLargerThanXl] = useAtom(isDeviceLargerThanXlAtom);
 
   useEffect(() => {
-    if (isClient()) {
-      const xlOrAboveHandler = function (this: MediaQueryList): void {
-        // lg -> xl: matches will be true
-        // xl -> lg: matches will be false
-        setIsLargerThanXl(this.matches);
-      };
-      const mql = addBreakpointListener(Breakpoint.XL, xlOrAboveHandler);
-
-      // initialize
-      setIsLargerThanXl(mql.matches);
-
-      return () => {
-        cleanupBreakpointListener(mql, xlOrAboveHandler);
-      };
-    }
-    return undefined;
+    const xlOrAboveHandler = function (this: MediaQueryList): void {
+      // lg -> xl: matches will be true
+      // xl -> lg: matches will be false
+      setIsLargerThanXl(this.matches);
+    };
+    const mql = addBreakpointListener(Breakpoint.XL, xlOrAboveHandler);
+
+    // initialize
+    setIsLargerThanXl(mql.matches);
+
+    return () => {
+      cleanupBreakpointListener(mql, xlOrAboveHandler);
+    };
   }, [setIsLargerThanXl]);
 
   return [isLargerThanXl, setIsLargerThanXl] as const;
 };
+
+export const useDeviceLargerThanLg = () => {
+  const [isLargerThanLg, setIsLargerThanLg] = useAtom(isDeviceLargerThanLgAtom);
+
+  useEffect(() => {
+    const lgOrAboveHandler = function (this: MediaQueryList): void {
+      // md -> lg: matches will be true
+      // lg -> md: matches will be false
+      setIsLargerThanLg(this.matches);
+    };
+    const mql = addBreakpointListener(Breakpoint.LG, lgOrAboveHandler);
+
+    // initialize
+    setIsLargerThanLg(mql.matches);
+
+    return () => {
+      cleanupBreakpointListener(mql, lgOrAboveHandler);
+    };
+  }, [setIsLargerThanLg]);
+
+  return [isLargerThanLg, setIsLargerThanLg] as const;
+};
+
+export const useDeviceLargerThanMd = () => {
+  const [isLargerThanMd, setIsLargerThanMd] = useAtom(isDeviceLargerThanMdAtom);
+
+  useEffect(() => {
+    const mdOrAboveHandler = function (this: MediaQueryList): void {
+      // sm -> md: matches will be true
+      // md -> sm: matches will be false
+      setIsLargerThanMd(this.matches);
+    };
+    const mql = addBreakpointListener(Breakpoint.MD, mdOrAboveHandler);
+
+    // initialize
+    setIsLargerThanMd(mql.matches);
+
+    return () => {
+      cleanupBreakpointListener(mql, mdOrAboveHandler);
+    };
+  }, [setIsLargerThanMd]);
+
+  return [isLargerThanMd, setIsLargerThanMd] as const;
+};
+
+export const useIsMobile = () => {
+  const [isMobile, setIsMobile] = useAtom(isMobileAtom);
+
+  useEffect(() => {
+    // Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_device_detection
+    let hasTouchScreen = false;
+    hasTouchScreen = ('maxTouchPoints' in navigator) ? navigator?.maxTouchPoints > 0 : false;
+
+    if (!hasTouchScreen) {
+      const mQ = matchMedia?.('(pointer:coarse)');
+      if (mQ?.media === '(pointer:coarse)') {
+        hasTouchScreen = !!mQ.matches;
+      }
+      else {
+        // Only as a last resort, fall back to user agent sniffing
+        const UA = navigator.userAgent;
+        hasTouchScreen = /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA)
+          || /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
+      }
+    }
+
+    // Initialize with detected value
+    setIsMobile(hasTouchScreen);
+  }, [setIsMobile]);
+
+  return [isMobile, setIsMobile] as const;
+};

+ 5 - 98
apps/app/src/stores/ui.tsx

@@ -1,16 +1,14 @@
 import {
-  type RefObject, useCallback, useEffect,
+  type RefObject, useCallback,
   useLayoutEffect,
 } from 'react';
 
 import { useSWRStatic } from '@growi/core/dist/swr';
-import { pagePathUtils, isClient } from '@growi/core/dist/utils';
-import { Breakpoint } from '@growi/ui/dist/interfaces';
-import { addBreakpointListener, cleanupBreakpointListener } from '@growi/ui/dist/utils';
+import { pagePathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
 import type { HtmlElementNode } from 'rehype-toc';
 import {
-  useSWRConfig, type SWRResponse, type Key,
+  type SWRResponse,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
@@ -52,97 +50,6 @@ export const useSidebarScrollerRef = (initialData?: RefObject<HTMLDivElement | n
   return useSWRStatic<RefObject<HTMLDivElement | null>, Error>('sidebarScrollerRef', initialData);
 };
 
-//
-export const useIsMobile = (): SWRResponse<boolean, Error> => {
-  const key = isClient() ? 'isMobile' : null;
-
-  let configuration = {
-    fallbackData: false,
-  };
-
-  if (isClient()) {
-
-    // Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_device_detection
-    let hasTouchScreen = false;
-    hasTouchScreen = ('maxTouchPoints' in navigator) ? navigator?.maxTouchPoints > 0 : false;
-
-    if (!hasTouchScreen) {
-      const mQ = matchMedia?.('(pointer:coarse)');
-      if (mQ?.media === '(pointer:coarse)') {
-        hasTouchScreen = !!mQ.matches;
-      }
-      else {
-      // Only as a last resort, fall back to user agent sniffing
-        const UA = navigator.userAgent;
-        hasTouchScreen = /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA)
-      || /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
-      }
-    }
-
-    configuration = {
-      fallbackData: hasTouchScreen,
-    };
-  }
-
-  return useSWRStatic<boolean, Error>(key, undefined, configuration);
-};
-
-export const useIsDeviceLargerThanMd = (): SWRResponse<boolean, Error> => {
-  const key: Key = isClient() ? 'isDeviceLargerThanMd' : null;
-
-  const { cache, mutate } = useSWRConfig();
-
-  useEffect(() => {
-    if (key != null) {
-      const mdOrAvobeHandler = function(this: MediaQueryList): void {
-        // sm -> md: matches will be true
-        // md -> sm: matches will be false
-        mutate(key, this.matches);
-      };
-      const mql = addBreakpointListener(Breakpoint.MD, mdOrAvobeHandler);
-
-      // initialize
-      if (cache.get(key)?.data == null) {
-        cache.set(key, { ...cache.get(key), data: mql.matches });
-      }
-
-      return () => {
-        cleanupBreakpointListener(mql, mdOrAvobeHandler);
-      };
-    }
-  }, [cache, key, mutate]);
-
-  return useSWRStatic(key);
-};
-
-export const useIsDeviceLargerThanLg = (): SWRResponse<boolean, Error> => {
-  const key: Key = isClient() ? 'isDeviceLargerThanLg' : null;
-
-  const { cache, mutate } = useSWRConfig();
-
-  useEffect(() => {
-    if (key != null) {
-      const lgOrAvobeHandler = function(this: MediaQueryList): void {
-        // md -> lg: matches will be true
-        // lg -> md: matches will be false
-        mutate(key, this.matches);
-      };
-      const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler);
-
-      // initialize
-      if (cache.get(key)?.data == null) {
-        cache.set(key, { ...cache.get(key), data: mql.matches });
-      }
-
-      return () => {
-        cleanupBreakpointListener(mql, lgOrAvobeHandler);
-      };
-    }
-  }, [cache, key, mutate]);
-
-  return useSWRStatic(key);
-};
-
 type PageTreeDescCountMapUtils = {
   update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
   getDescCount(pageId?: string): number | null | undefined
@@ -173,7 +80,7 @@ export const useCommentEditorDirtyMap = (): SWRResponse<Map<string, boolean>, Er
 
   const { mutate } = swrResponse;
 
-  const evaluate = useCallback(async(key: string, commentBody: string) => {
+  const evaluate = useCallback(async (key: string, commentBody: string) => {
     const newMap = await mutate((map) => {
       if (map == null) return new Map();
 
@@ -188,7 +95,7 @@ export const useCommentEditorDirtyMap = (): SWRResponse<Map<string, boolean>, Er
     });
     return newMap?.size ?? 0;
   }, [mutate]);
-  const clean = useCallback(async(key: string) => {
+  const clean = useCallback(async (key: string) => {
     const newMap = await mutate((map) => {
       if (map == null) return new Map();
       map.delete(key);