fix: mention closure, onSettled invalidation, cleanup singleton

- Fix Tiptap mention: pass QueryClient via closure from ContentEditor
  instead of getQueryClient() singleton (resolves @mention empty list)
- Add onSettled invalidation to useUpdateIssue (prevents cache drift
  with staleTime: Infinity + self-event WS filter)
- Add cache shape comment to issueListOptions (select transforms
  ListIssuesResponse → Issue[], but cache stores raw response)
- Memoize sidebar inbox dedup computation
- Remove dead getQueryClient/setQueryClient singleton + window property
- Remove ActorSync component and _members/_agents Zustand mirror
  (superseded by closure approach)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-07 17:53:49 +08:00
parent e40341ab73
commit 6032b5dfcb
9 changed files with 28 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import React from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
@@ -77,7 +78,10 @@ export function AppSidebar() {
const wsId = useWorkspaceId(); const wsId = useWorkspaceId();
const { data: inboxItems = [] } = useQuery(inboxListOptions(wsId)); const { data: inboxItems = [] } = useQuery(inboxListOptions(wsId));
const unreadCount = deduplicateInboxItems(inboxItems).filter((i) => !i.read).length; const unreadCount = React.useMemo(
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const logout = () => { const logout = () => {
router.push("/"); router.push("/");

View File

@@ -1,3 +1,3 @@
export { createQueryClient, getQueryClient, setQueryClient } from "./query-client"; export { createQueryClient } from "./query-client";
export { QueryProvider } from "./provider"; export { QueryProvider } from "./provider";
export { useWorkspaceId } from "./hooks"; export { useWorkspaceId } from "./hooks";

View File

@@ -60,6 +60,9 @@ export function useUpdateIssue() {
if (ctx?.prevDetail) if (ctx?.prevDetail)
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
}, },
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
},
}); });
} }

View File

@@ -12,6 +12,11 @@ export const issueKeys = {
["issues", "subscribers", issueId] as const, ["issues", "subscribers", issueId] as const,
}; };
/**
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
*/
export function issueListOptions(wsId: string) { export function issueListOptions(wsId: string) {
return queryOptions({ return queryOptions({
queryKey: issueKeys.list(wsId), queryKey: issueKeys.list(wsId),

View File

@@ -3,15 +3,11 @@
import { useState } from "react"; import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient, setQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function QueryProvider({ children }: { children: ReactNode }) { export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => { const [queryClient] = useState(createQueryClient);
const client = createQueryClient();
setQueryClient(client);
return client;
});
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}

View File

@@ -1,7 +1,5 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
let _queryClient: QueryClient | null = null;
export function createQueryClient(): QueryClient { export function createQueryClient(): QueryClient {
return new QueryClient({ return new QueryClient({
defaultOptions: { defaultOptions: {
@@ -18,14 +16,3 @@ export function createQueryClient(): QueryClient {
}, },
}); });
} }
/** Called by QueryProvider on mount to register the singleton. */
export function setQueryClient(client: QueryClient) {
_queryClient = client;
}
/** Access QueryClient outside React tree (WS handlers, Zustand actions). */
export function getQueryClient(): QueryClient {
if (!_queryClient) throw new Error("QueryClient not initialized");
return _queryClient;
}

View File

@@ -34,6 +34,7 @@ import {
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload"; import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { useQueryClient } from "@tanstack/react-query";
import { createEditorExtensions } from "./extensions"; import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload"; import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess"; import { preprocessMarkdown } from "./utils/preprocess";
@@ -94,6 +95,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onBlurRef.current = onBlur; onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile; onUploadFileRef.current = onUploadFile;
const queryClient = useQueryClient();
const editor = useEditor({ const editor = useEditor({
immediatelyRender: false, immediatelyRender: false,
editable, editable,
@@ -102,6 +105,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
extensions: createEditorExtensions({ extensions: createEditorExtensions({
editable, editable,
placeholder: placeholderText, placeholder: placeholderText,
queryClient,
onSubmitRef, onSubmitRef,
onUploadFileRef, onUploadFileRef,
}), }),

View File

@@ -76,6 +76,7 @@ const ImageExtension = Image.extend({
export interface EditorExtensionsOptions { export interface EditorExtensionsOptions {
editable: boolean; editable: boolean;
placeholder?: string; placeholder?: string;
queryClient?: import("@tanstack/react-query").QueryClient;
onSubmitRef?: RefObject<(() => void) | undefined>; onSubmitRef?: RefObject<(() => void) | undefined>;
onUploadFileRef?: RefObject< onUploadFileRef?: RefObject<
((file: File) => Promise<UploadResult | null>) | undefined ((file: File) => Promise<UploadResult | null>) | undefined
@@ -107,7 +108,7 @@ export function createEditorExtensions(
Markdown, Markdown,
BaseMentionExtension.configure({ BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" }, HTMLAttributes: { class: "mention" },
...(editable ? { suggestion: createMentionSuggestion() } : {}), ...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}),
}), }),
]; ];

View File

@@ -10,8 +10,8 @@ import {
} from "react"; } from "react";
import { ReactRenderer } from "@tiptap/react"; import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom"; import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import type { QueryClient } from "@tanstack/react-query";
import { useWorkspaceStore } from "@/features/workspace"; import { useWorkspaceStore } from "@/features/workspace";
import { getQueryClient } from "@core/query-client";
import { issueKeys } from "@core/issues/queries"; import { issueKeys } from "@core/issues/queries";
import { workspaceKeys } from "@core/workspace/queries"; import { workspaceKeys } from "@core/workspace/queries";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types"; import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
@@ -213,18 +213,19 @@ function MentionRow({
// Suggestion config factory // Suggestion config factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit< export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>, SuggestionOptions<MentionItem>,
"editor" "editor"
> { > {
return { return {
items: ({ query }) => { items: ({ query }) => {
const wsId = useWorkspaceStore.getState().workspace?.id; const wsId = useWorkspaceStore.getState().workspace?.id;
const members: MemberWithUser[] = wsId ? getQueryClient().getQueryData(workspaceKeys.members(wsId)) ?? [] : []; const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
const agents: Agent[] = wsId ? getQueryClient().getQueryData(workspaceKeys.agents(wsId)) ?? [] : []; const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
const issues: Issue[] = wsId const issues: Issue[] = wsId
? getQueryClient().getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? [] ? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
: []; : [];
const q = query.toLowerCase(); const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all" // Show "All members" option when query is empty or matches "all"