Commit b2e2b642 authored by Steven's avatar Steven

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: 's avatarClaude Sonnet 4.5 <noreply@anthropic.com>
parent f87f728b
...@@ -3,6 +3,7 @@ import { useMemo, useRef } from "react"; ...@@ -3,6 +3,7 @@ import { useMemo, useRef } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries"; import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components"; import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
...@@ -119,7 +120,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -119,7 +120,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Invalidate React Query cache to refresh memo lists across the app // Invalidate React Query cache to refresh memo lists across the app
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }), queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ["users", "stats"] }), queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
]); ]);
// Reset editor state to initial values // Reset editor state to initial values
......
import { useQueryClient } from "@tanstack/react-query"; 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 { clearAccessToken } from "@/auth-state";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
...@@ -114,23 +114,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -114,23 +114,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [queryClient]); }, [queryClient]);
const refetchSettings = useCallback(async () => { const refetchSettings = useCallback(async () => {
if (!state.currentUser) return; // Use functional setState to get current user without including state in dependencies
const settings = await fetchUserSettings(state.currentUser.name); setState((prev) => {
setState((prev) => ({ ...prev, ...settings })); if (!prev.currentUser) return prev;
}, [state.currentUser, fetchUserSettings]);
// Fetch settings asynchronously
return ( fetchUserSettings(prev.currentUser.name).then((settings) => {
<AuthContext.Provider setState((current) => ({ ...current, ...settings }));
value={{ });
return prev;
});
}, [fetchUserSettings]);
// Memoize context value to prevent unnecessary re-renders of consumers
const value = useMemo(
() => ({
...state, ...state,
initialize, initialize,
logout, logout,
refetchSettings, refetchSettings,
}} }),
> [state, initialize, logout, refetchSettings],
{children}
</AuthContext.Provider>
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }
export function useAuth() { export function useAuth() {
......
import { create } from "@bufbuild/protobuf"; 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 { instanceServiceClient } from "@/connect";
import { updateInstanceConfig } from "@/instance-config"; import { updateInstanceConfig } from "@/instance-config";
import { import {
...@@ -48,29 +48,30 @@ export function InstanceProvider({ children }: { children: ReactNode }) { ...@@ -48,29 +48,30 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
isLoading: true, 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`); const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}GENERAL`);
if (setting?.value.case === "generalSetting") { if (setting?.value.case === "generalSetting") {
return setting.value.value; return setting.value.value;
} }
return create(InstanceSetting_GeneralSettingSchema, {}); 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`); const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}MEMO_RELATED`);
if (setting?.value.case === "memoRelatedSetting") { if (setting?.value.case === "memoRelatedSetting") {
return setting.value.value; return setting.value.value;
} }
return create(InstanceSetting_MemoRelatedSettingSchema, {}); 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`); const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}STORAGE`);
if (setting?.value.case === "storageSetting") { if (setting?.value.case === "storageSetting") {
return setting.value.value; return setting.value.value;
} }
return create(InstanceSetting_StorageSettingSchema, {}); return create(InstanceSetting_StorageSettingSchema, {});
}; }, [state.settings]);
const initialize = useCallback(async () => { const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true })); setState((prev) => ({ ...prev, isLoading: true }));
...@@ -125,21 +126,21 @@ export function InstanceProvider({ children }: { children: ReactNode }) { ...@@ -125,21 +126,21 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
})); }));
}, []); }, []);
return ( // Memoize context value to prevent unnecessary re-renders of consumers
<InstanceContext.Provider const value = useMemo(
value={{ () => ({
...state, ...state,
generalSetting: getGeneralSetting(), generalSetting,
memoRelatedSetting: getMemoRelatedSetting(), memoRelatedSetting,
storageSetting: getStorageSetting(), storageSetting,
initialize, initialize,
fetchSetting, fetchSetting,
updateSetting, updateSetting,
}} }),
> [state, generalSetting, memoRelatedSetting, storageSetting, initialize, fetchSetting, updateSetting],
{children}
</InstanceContext.Provider>
); );
return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;
} }
export function useInstance() { export function useInstance() {
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { attachmentServiceClient } from "@/connect"; import { attachmentServiceClient } from "@/connect";
import type { Attachment, ListAttachmentsRequest } from "@/types/proto/api/v1/attachment_service_pb";
// Query keys factory // Query keys factory
export const attachmentKeys = { export const attachmentKeys = {
all: ["attachments"] as const, all: ["attachments"] as const,
lists: () => [...attachmentKeys.all, "list"] 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, details: () => [...attachmentKeys.all, "detail"] as const,
detail: (name: string) => [...attachmentKeys.details(), name] as const, detail: (name: string) => [...attachmentKeys.details(), name] as const,
}; };
...@@ -26,7 +27,7 @@ export function useCreateAttachment() { ...@@ -26,7 +27,7 @@ export function useCreateAttachment() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (attachment: any) => { mutationFn: async (attachment: Attachment) => {
const result = await attachmentServiceClient.createAttachment({ attachment }); const result = await attachmentServiceClient.createAttachment({ attachment });
return result; return result;
}, },
......
...@@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf"; ...@@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { memoServiceClient } from "@/connect"; import { memoServiceClient } from "@/connect";
import { userKeys } from "@/hooks/useUserQueries";
import type { ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service_pb";
import { ListMemosRequestSchema, MemoSchema } 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() { ...@@ -89,7 +90,7 @@ export function useCreateMemo() {
// Add new memo to cache // Add new memo to cache
queryClient.setQueryData(memoKeys.detail(newMemo.name), newMemo); queryClient.setQueryData(memoKeys.detail(newMemo.name), newMemo);
// Invalidate user stats // Invalidate user stats
queryClient.invalidateQueries({ queryKey: ["users", "stats"] }); queryClient.invalidateQueries({ queryKey: userKeys.stats() });
}, },
}); });
} }
...@@ -139,7 +140,7 @@ export function useUpdateMemo() { ...@@ -139,7 +140,7 @@ export function useUpdateMemo() {
// Invalidate lists to refresh // Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
// Invalidate user stats // Invalidate user stats
queryClient.invalidateQueries({ queryKey: ["users", "stats"] }); queryClient.invalidateQueries({ queryKey: userKeys.stats() });
}, },
}); });
} }
...@@ -162,7 +163,7 @@ export function useDeleteMemo() { ...@@ -162,7 +163,7 @@ export function useDeleteMemo() {
// Invalidate lists // Invalidate lists
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
// Invalidate user stats // Invalidate user stats
queryClient.invalidateQueries({ queryKey: ["users", "stats"] }); queryClient.invalidateQueries({ queryKey: userKeys.stats() });
}, },
}); });
} }
...@@ -18,10 +18,19 @@ export const userKeys = { ...@@ -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. * Data is cached for 5 minutes as auth state changes infrequently.
*/ */
export function useCurrentUser() { export function useCurrentUserQuery() {
return useQuery({ return useQuery({
queryKey: userKeys.currentUser(), queryKey: userKeys.currentUser(),
queryFn: async () => { queryFn: async () => {
...@@ -85,7 +94,7 @@ export function useShortcuts() { ...@@ -85,7 +94,7 @@ export function useShortcuts() {
* Only fetches when a user is authenticated. * Only fetches when a user is authenticated.
*/ */
export function useNotifications() { export function useNotifications() {
const { data: currentUser } = useCurrentUser(); const { data: currentUser } = useCurrentUserQuery();
return useQuery({ return useQuery({
queryKey: userKeys.notifications(), queryKey: userKeys.notifications(),
...@@ -106,7 +115,7 @@ export function useNotifications() { ...@@ -106,7 +115,7 @@ export function useNotifications() {
* @param forCurrentUser - If true, fetches only current user's tags; if false, fetches all public tags * @param forCurrentUser - If true, fetches only current user's tags; if false, fetches all public tags
*/ */
export function useTagCounts(forCurrentUser = false) { export function useTagCounts(forCurrentUser = false) {
const { data: currentUser } = useCurrentUser(); const { data: currentUser } = useCurrentUserQuery();
return useQuery({ return useQuery({
queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"], queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment