From b2e2b6426cf14f49402c79dd40db838a9aaf5e32 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 25 Dec 2025 08:29:34 +0800 Subject: [PATCH] perf(react-query): fix context re-renders and improve type safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimizes React Query migration with performance and consistency improvements: Performance: - Memoize AuthContext and InstanceContext provider values to prevent unnecessary re-renders - Convert InstanceContext getter functions to useMemo hooks - Fix refetchSettings to avoid state dependency that caused frequent recreations Type Safety: - Replace 'any' types in useAttachmentQueries with proper protobuf types - Add Attachment and ListAttachmentsRequest type imports Query Key Consistency: - Replace hardcoded ["users", "stats"] with userKeys.stats() factory function - Ensures consistent cache key management across mutations Developer Experience: - Rename unused useCurrentUser to useCurrentUserQuery to avoid confusion - Add documentation explaining AuthContext-based vs React Query current user hooks - Update internal references in useNotifications and useTagCounts All changes verified with TypeScript compilation and build tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- web/src/components/MemoEditor/index.tsx | 3 +- web/src/contexts/AuthContext.tsx | 40 ++++++++++++++--------- web/src/contexts/InstanceContext.tsx | 43 +++++++++++++------------ web/src/hooks/useAttachmentQueries.ts | 5 +-- web/src/hooks/useMemoQueries.ts | 7 ++-- web/src/hooks/useUserQueries.ts | 17 +++++++--- 6 files changed, 68 insertions(+), 47 deletions(-) diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 88eb911fc..970372b02 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef } from "react"; import { toast } from "react-hot-toast"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; +import { userKeys } from "@/hooks/useUserQueries"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components"; @@ -119,7 +120,7 @@ const MemoEditorImpl: React.FC = ({ // Invalidate React Query cache to refresh memo lists across the app await Promise.all([ queryClient.invalidateQueries({ queryKey: memoKeys.lists() }), - queryClient.invalidateQueries({ queryKey: ["users", "stats"] }), + queryClient.invalidateQueries({ queryKey: userKeys.stats() }), ]); // Reset editor state to initial values diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 7f8196510..4615d2215 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import { createContext, type ReactNode, useCallback, useContext, useState } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; import { clearAccessToken } from "@/auth-state"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect"; import { userKeys } from "@/hooks/useUserQueries"; @@ -114,23 +114,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [queryClient]); const refetchSettings = useCallback(async () => { - if (!state.currentUser) return; - const settings = await fetchUserSettings(state.currentUser.name); - setState((prev) => ({ ...prev, ...settings })); - }, [state.currentUser, fetchUserSettings]); + // Use functional setState to get current user without including state in dependencies + setState((prev) => { + if (!prev.currentUser) return prev; - return ( - - {children} - + // Fetch settings asynchronously + fetchUserSettings(prev.currentUser.name).then((settings) => { + setState((current) => ({ ...current, ...settings })); + }); + + return prev; + }); + }, [fetchUserSettings]); + + // Memoize context value to prevent unnecessary re-renders of consumers + const value = useMemo( + () => ({ + ...state, + initialize, + logout, + refetchSettings, + }), + [state, initialize, logout, refetchSettings], ); + + return {children}; } export function useAuth() { diff --git a/web/src/contexts/InstanceContext.tsx b/web/src/contexts/InstanceContext.tsx index df5db1e5e..8cd0656af 100644 --- a/web/src/contexts/InstanceContext.tsx +++ b/web/src/contexts/InstanceContext.tsx @@ -1,5 +1,5 @@ import { create } from "@bufbuild/protobuf"; -import { createContext, type ReactNode, useCallback, useContext, useState } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; import { instanceServiceClient } from "@/connect"; import { updateInstanceConfig } from "@/instance-config"; import { @@ -48,29 +48,30 @@ export function InstanceProvider({ children }: { children: ReactNode }) { isLoading: true, }); - const getGeneralSetting = (): InstanceSetting_GeneralSetting => { + // Memoize derived settings to prevent unnecessary recalculations + const generalSetting = useMemo((): InstanceSetting_GeneralSetting => { const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}GENERAL`); if (setting?.value.case === "generalSetting") { return setting.value.value; } return create(InstanceSetting_GeneralSettingSchema, {}); - }; + }, [state.settings]); - const getMemoRelatedSetting = (): InstanceSetting_MemoRelatedSetting => { + const memoRelatedSetting = useMemo((): InstanceSetting_MemoRelatedSetting => { const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}MEMO_RELATED`); if (setting?.value.case === "memoRelatedSetting") { return setting.value.value; } return create(InstanceSetting_MemoRelatedSettingSchema, {}); - }; + }, [state.settings]); - const getStorageSetting = (): InstanceSetting_StorageSetting => { + const storageSetting = useMemo((): InstanceSetting_StorageSetting => { const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}STORAGE`); if (setting?.value.case === "storageSetting") { return setting.value.value; } return create(InstanceSetting_StorageSettingSchema, {}); - }; + }, [state.settings]); const initialize = useCallback(async () => { setState((prev) => ({ ...prev, isLoading: true })); @@ -125,21 +126,21 @@ export function InstanceProvider({ children }: { children: ReactNode }) { })); }, []); - return ( - - {children} - + // Memoize context value to prevent unnecessary re-renders of consumers + const value = useMemo( + () => ({ + ...state, + generalSetting, + memoRelatedSetting, + storageSetting, + initialize, + fetchSetting, + updateSetting, + }), + [state, generalSetting, memoRelatedSetting, storageSetting, initialize, fetchSetting, updateSetting], ); + + return {children}; } export function useInstance() { diff --git a/web/src/hooks/useAttachmentQueries.ts b/web/src/hooks/useAttachmentQueries.ts index da078f447..564c92947 100644 --- a/web/src/hooks/useAttachmentQueries.ts +++ b/web/src/hooks/useAttachmentQueries.ts @@ -1,11 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { attachmentServiceClient } from "@/connect"; +import type { Attachment, ListAttachmentsRequest } from "@/types/proto/api/v1/attachment_service_pb"; // Query keys factory export const attachmentKeys = { all: ["attachments"] as const, lists: () => [...attachmentKeys.all, "list"] as const, - list: (filters?: any) => [...attachmentKeys.lists(), filters] as const, + list: (filters?: Partial) => [...attachmentKeys.lists(), filters] as const, details: () => [...attachmentKeys.all, "detail"] as const, detail: (name: string) => [...attachmentKeys.details(), name] as const, }; @@ -26,7 +27,7 @@ export function useCreateAttachment() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (attachment: any) => { + mutationFn: async (attachment: Attachment) => { const result = await attachmentServiceClient.createAttachment({ attachment }); return result; }, diff --git a/web/src/hooks/useMemoQueries.ts b/web/src/hooks/useMemoQueries.ts index 9bc6b094d..c653c273c 100644 --- a/web/src/hooks/useMemoQueries.ts +++ b/web/src/hooks/useMemoQueries.ts @@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { memoServiceClient } from "@/connect"; +import { userKeys } from "@/hooks/useUserQueries"; import type { ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service_pb"; import { ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; @@ -89,7 +90,7 @@ export function useCreateMemo() { // Add new memo to cache queryClient.setQueryData(memoKeys.detail(newMemo.name), newMemo); // Invalidate user stats - queryClient.invalidateQueries({ queryKey: ["users", "stats"] }); + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); }, }); } @@ -139,7 +140,7 @@ export function useUpdateMemo() { // Invalidate lists to refresh queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); // Invalidate user stats - queryClient.invalidateQueries({ queryKey: ["users", "stats"] }); + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); }, }); } @@ -162,7 +163,7 @@ export function useDeleteMemo() { // Invalidate lists queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); // Invalidate user stats - queryClient.invalidateQueries({ queryKey: ["users", "stats"] }); + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); }, }); } diff --git a/web/src/hooks/useUserQueries.ts b/web/src/hooks/useUserQueries.ts index 5b3d56a6d..0d2122026 100644 --- a/web/src/hooks/useUserQueries.ts +++ b/web/src/hooks/useUserQueries.ts @@ -18,10 +18,19 @@ export const userKeys = { }; /** - * Hook to get the current authenticated user. + * Hook to get the current authenticated user via React Query. + * + * NOTE: This hook is currently UNUSED in favor of the AuthContext-based + * useCurrentUser hook (src/hooks/useCurrentUser.ts) which provides the same + * data from AuthContext. The AuthContext fetches user data on app initialization + * and stores it in React state, avoiding unnecessary re-fetches. + * + * This hook is kept for potential future use if we decide to fully migrate + * auth state to React Query, but currently all components use the AuthContext version. + * * Data is cached for 5 minutes as auth state changes infrequently. */ -export function useCurrentUser() { +export function useCurrentUserQuery() { return useQuery({ queryKey: userKeys.currentUser(), queryFn: async () => { @@ -85,7 +94,7 @@ export function useShortcuts() { * Only fetches when a user is authenticated. */ export function useNotifications() { - const { data: currentUser } = useCurrentUser(); + const { data: currentUser } = useCurrentUserQuery(); return useQuery({ queryKey: userKeys.notifications(), @@ -106,7 +115,7 @@ export function useNotifications() { * @param forCurrentUser - If true, fetches only current user's tags; if false, fetches all public tags */ export function useTagCounts(forCurrentUser = false) { - const { data: currentUser } = useCurrentUser(); + const { data: currentUser } = useCurrentUserQuery(); return useQuery({ queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"],