feat(web): add TanStack Query infrastructure (Phase 0)

- Install @tanstack/react-query v5 + devtools
- Create core/query-client.ts with WS-optimized defaults (staleTime: Infinity)
- Create QueryProvider and wire into root layout
- Add @core/* path alias to tsconfig + vitest
- Add useWorkspaceId() bridge hook for query key scoping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-07 14:43:51 +08:00
parent 5fe1ec806d
commit 2be9f6cd2f
9 changed files with 108 additions and 6 deletions

View file

@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QueryProvider } from "@core/provider";
import { AuthInitializer } from "@/features/auth"; import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime"; import { WSProvider } from "@/features/realtime";
import { ModalRegistry } from "@/features/modals"; import { ModalRegistry } from "@/features/modals";
@ -67,11 +68,13 @@ export default async function RootLayout({
> >
<body className="h-full overflow-hidden"> <body className="h-full overflow-hidden">
<ThemeProvider> <ThemeProvider>
<AuthInitializer> <QueryProvider>
<WSProvider>{children}</WSProvider> <AuthInitializer>
</AuthInitializer> <WSProvider>{children}</WSProvider>
<ModalRegistry /> </AuthInitializer>
<Toaster /> <ModalRegistry />
<Toaster />
</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

17
apps/web/core/hooks.ts Normal file
View file

@ -0,0 +1,17 @@
"use client";
import { useWorkspaceStore } from "@/features/workspace";
/**
* Returns the current workspace ID.
*
* Bridge hook: reads from Zustand workspace store now.
* Phase 3 will switch to core/workspace/store.ts signature stays the same.
*/
export function useWorkspaceId(): string {
const workspaceId = useWorkspaceStore((s) => s.workspace?.id);
if (!workspaceId) {
throw new Error("useWorkspaceId: no workspace selected");
}
return workspaceId;
}

3
apps/web/core/index.ts Normal file
View file

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

View file

@ -0,0 +1,17 @@
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "./query-client";
import type { ReactNode } from "react";
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

View file

@ -0,0 +1,18 @@
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 1,
},
mutations: {
retry: false,
},
},
});
}

View file

@ -18,11 +18,12 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.6", "@floating-ui/dom": "^1.7.6",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tiptap/extension-code-block-lowlight": "^3.22.1", "@tiptap/extension-code-block-lowlight": "^3.22.1",
"@tiptap/extension-image": "^3.22.1", "@tiptap/extension-image": "^3.22.1",
"@tiptap/extension-link": "^3.22.1", "@tiptap/extension-link": "^3.22.1",
"@tiptap/extension-mention": "^3.22.1", "@tiptap/extension-mention": "^3.22.1",
"@tiptap/suggestion": "^3.22.1",
"@tiptap/extension-placeholder": "^3.22.1", "@tiptap/extension-placeholder": "^3.22.1",
"@tiptap/extension-table": "^3.22.1", "@tiptap/extension-table": "^3.22.1",
"@tiptap/extension-table-cell": "^3.22.1", "@tiptap/extension-table-cell": "^3.22.1",
@ -33,6 +34,7 @@
"@tiptap/pm": "^3.22.1", "@tiptap/pm": "^3.22.1",
"@tiptap/react": "^3.22.1", "@tiptap/react": "^3.22.1",
"@tiptap/starter-kit": "^3.22.1", "@tiptap/starter-kit": "^3.22.1",
"@tiptap/suggestion": "^3.22.1",
"@types/linkify-it": "^5.0.0", "@types/linkify-it": "^5.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -28,6 +28,9 @@
"paths": { "paths": {
"@/*": [ "@/*": [
"./*" "./*"
],
"@core/*": [
"./core/*"
] ]
}, },
"noEmit": true, "noEmit": true,

View file

@ -13,6 +13,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "."), "@": path.resolve(__dirname, "."),
"@core": path.resolve(__dirname, "core"),
}, },
}, },
}); });

38
pnpm-lock.yaml generated
View file

@ -75,6 +75,12 @@ importers:
'@floating-ui/dom': '@floating-ui/dom':
specifier: ^1.7.6 specifier: ^1.7.6
version: 1.7.6 version: 1.7.6
'@tanstack/react-query':
specifier: ^5.96.2
version: 5.96.2(react@19.2.3)
'@tanstack/react-query-devtools':
specifier: ^5.96.2
version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)
'@tiptap/extension-code-block-lowlight': '@tiptap/extension-code-block-lowlight':
specifier: ^3.22.1 specifier: ^3.22.1
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0) version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
@ -1288,6 +1294,23 @@ packages:
'@tailwindcss/postcss@4.2.2': '@tailwindcss/postcss@4.2.2':
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
'@tanstack/query-core@5.96.2':
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
'@tanstack/query-devtools@5.96.2':
resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==}
'@tanstack/react-query-devtools@5.96.2':
resolution: {integrity: sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw==}
peerDependencies:
'@tanstack/react-query': ^5.96.2
react: ^18 || ^19
'@tanstack/react-query@5.96.2':
resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==}
peerDependencies:
react: ^18 || ^19
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -4917,6 +4940,21 @@ snapshots:
postcss: 8.5.8 postcss: 8.5.8
tailwindcss: 4.2.2 tailwindcss: 4.2.2
'@tanstack/query-core@5.96.2': {}
'@tanstack/query-devtools@5.96.2': {}
'@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/query-devtools': 5.96.2
'@tanstack/react-query': 5.96.2(react@19.2.3)
react: 19.2.3
'@tanstack/react-query@5.96.2(react@19.2.3)':
dependencies:
'@tanstack/query-core': 5.96.2
react: 19.2.3
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0