perf(react-query): fix context re-renders and improve type safety

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 <noreply@anthropic.com>
This commit is contained in:
Steven 2025-12-25 08:29:34 +08:00
parent f87f728b0f
commit b2e2b6426c
6 changed files with 68 additions and 47 deletions

View File

@ -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<MemoEditorProps> = ({
// 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

View File

@ -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 (
<AuthContext.Provider
value={{
...state,
initialize,
logout,
refetchSettings,
}}
>
{children}
</AuthContext.Provider>
// 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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {

View File

@ -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 (
<InstanceContext.Provider
value={{
...state,
generalSetting: getGeneralSetting(),
memoRelatedSetting: getMemoRelatedSetting(),
storageSetting: getStorageSetting(),
initialize,
fetchSetting,
updateSetting,
}}
>
{children}
</InstanceContext.Provider>
// 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 <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;
}
export function useInstance() {

View File

@ -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<ListAttachmentsRequest>) => [...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;
},

View File

@ -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() });
},
});
}

View File

@ -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"],