aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/atoms/cards.ts22
-rw-r--r--src/client/atoms/decks.ts39
-rw-r--r--src/client/atoms/index.ts3
-rw-r--r--src/client/atoms/noteTypes.ts15
-rw-r--r--src/client/atoms/study.ts42
-rw-r--r--src/client/atoms/utils.ts81
-rw-r--r--src/client/main.tsx22
-rw-r--r--src/client/pages/DeckCardsPage.test.tsx33
-rw-r--r--src/client/pages/DeckCardsPage.tsx17
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx27
-rw-r--r--src/client/pages/DeckDetailPage.tsx4
-rw-r--r--src/client/pages/HomePage.test.tsx32
-rw-r--r--src/client/pages/HomePage.tsx11
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx25
-rw-r--r--src/client/pages/NoteTypesPage.tsx14
-rw-r--r--src/client/pages/StudyPage.test.tsx24
-rw-r--r--src/client/pages/StudyPage.tsx4
-rw-r--r--src/client/queryClient.ts10
18 files changed, 212 insertions, 213 deletions
diff --git a/src/client/atoms/cards.ts b/src/client/atoms/cards.ts
index f053ab9..c0275b0 100644
--- a/src/client/atoms/cards.ts
+++ b/src/client/atoms/cards.ts
@@ -1,5 +1,6 @@
+import { atomFamily } from "jotai/utils";
+import { atomWithSuspenseQuery } from "jotai-tanstack-query";
import { apiClient } from "../api/client";
-import { createReloadableAtomFamily } from "./utils";
export interface Card {
id: string;
@@ -20,12 +21,15 @@ export interface Card {
// Cards by Deck - Suspense-compatible
// =====================
-export const cardsByDeckAtomFamily = createReloadableAtomFamily(
- async (deckId: string) => {
- const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({
- param: { deckId },
- });
- const data = await apiClient.handleResponse<{ cards: Card[] }>(res);
- return data.cards;
- },
+export const cardsByDeckAtomFamily = atomFamily((deckId: string) =>
+ atomWithSuspenseQuery(() => ({
+ queryKey: ["decks", deckId, "cards"],
+ queryFn: async () => {
+ const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({
+ param: { deckId },
+ });
+ const data = await apiClient.handleResponse<{ cards: Card[] }>(res);
+ return data.cards;
+ },
+ })),
);
diff --git a/src/client/atoms/decks.ts b/src/client/atoms/decks.ts
index e9b0d03..fc68727 100644
--- a/src/client/atoms/decks.ts
+++ b/src/client/atoms/decks.ts
@@ -1,5 +1,6 @@
+import { atomFamily } from "jotai/utils";
+import { atomWithSuspenseQuery } from "jotai-tanstack-query";
import { apiClient } from "../api/client";
-import { createReloadableAtom, createReloadableAtomFamily } from "./utils";
export interface Deck {
id: string;
@@ -15,24 +16,30 @@ export interface Deck {
// Decks List - Suspense-compatible
// =====================
-export const decksAtom = createReloadableAtom(async () => {
- const res = await apiClient.rpc.api.decks.$get(undefined, {
- headers: apiClient.getAuthHeader(),
- });
- const data = await apiClient.handleResponse<{ decks: Deck[] }>(res);
- return data.decks;
-});
+export const decksAtom = atomWithSuspenseQuery(() => ({
+ queryKey: ["decks"],
+ queryFn: async () => {
+ const res = await apiClient.rpc.api.decks.$get(undefined, {
+ headers: apiClient.getAuthHeader(),
+ });
+ const data = await apiClient.handleResponse<{ decks: Deck[] }>(res);
+ return data.decks;
+ },
+}));
// =====================
// Single Deck by ID - Suspense-compatible
// =====================
-export const deckByIdAtomFamily = createReloadableAtomFamily(
- async (deckId: string) => {
- const res = await apiClient.rpc.api.decks[":id"].$get({
- param: { id: deckId },
- });
- const data = await apiClient.handleResponse<{ deck: Deck }>(res);
- return data.deck;
- },
+export const deckByIdAtomFamily = atomFamily((deckId: string) =>
+ atomWithSuspenseQuery(() => ({
+ queryKey: ["decks", deckId],
+ queryFn: async () => {
+ const res = await apiClient.rpc.api.decks[":id"].$get({
+ param: { id: deckId },
+ });
+ const data = await apiClient.handleResponse<{ deck: Deck }>(res);
+ return data.deck;
+ },
+ })),
);
diff --git a/src/client/atoms/index.ts b/src/client/atoms/index.ts
index 1e13222..1de2544 100644
--- a/src/client/atoms/index.ts
+++ b/src/client/atoms/index.ts
@@ -37,6 +37,3 @@ export {
syncStatusAtom,
useSyncInit,
} from "./sync";
-
-// Utilities
-export { createReloadableAtom, createReloadableAtomFamily } from "./utils";
diff --git a/src/client/atoms/noteTypes.ts b/src/client/atoms/noteTypes.ts
index adc9d44..fb99b14 100644
--- a/src/client/atoms/noteTypes.ts
+++ b/src/client/atoms/noteTypes.ts
@@ -1,5 +1,5 @@
+import { atomWithSuspenseQuery } from "jotai-tanstack-query";
import { apiClient } from "../api/client";
-import { createReloadableAtom } from "./utils";
export interface NoteType {
id: string;
@@ -15,8 +15,11 @@ export interface NoteType {
// NoteTypes List - Suspense-compatible
// =====================
-export const noteTypesAtom = createReloadableAtom(async () => {
- const res = await apiClient.rpc.api["note-types"].$get();
- const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>(res);
- return data.noteTypes;
-});
+export const noteTypesAtom = atomWithSuspenseQuery(() => ({
+ queryKey: ["noteTypes"],
+ queryFn: async () => {
+ const res = await apiClient.rpc.api["note-types"].$get();
+ const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>(res);
+ return data.noteTypes;
+ },
+}));
diff --git a/src/client/atoms/study.ts b/src/client/atoms/study.ts
index 2e3e1ea..1911737 100644
--- a/src/client/atoms/study.ts
+++ b/src/client/atoms/study.ts
@@ -1,6 +1,7 @@
+import { atomFamily } from "jotai/utils";
+import { atomWithSuspenseQuery } from "jotai-tanstack-query";
import { apiClient } from "../api/client";
import { shuffle } from "../utils/shuffle";
-import { createReloadableAtomFamily } from "./utils";
export interface StudyCard {
id: string;
@@ -36,24 +37,27 @@ export interface StudyData {
// Study Session - Suspense-compatible
// =====================
-export const studyDataAtomFamily = createReloadableAtomFamily(
- async (deckId: string): Promise<StudyData> => {
- // Fetch deck and due cards in parallel
- const [deckRes, cardsRes] = await Promise.all([
- apiClient.rpc.api.decks[":id"].$get({ param: { id: deckId } }),
- apiClient.rpc.api.decks[":deckId"].study.$get({ param: { deckId } }),
- ]);
+export const studyDataAtomFamily = atomFamily((deckId: string) =>
+ atomWithSuspenseQuery(() => ({
+ queryKey: ["decks", deckId, "study"],
+ queryFn: async (): Promise<StudyData> => {
+ // Fetch deck and due cards in parallel
+ const [deckRes, cardsRes] = await Promise.all([
+ apiClient.rpc.api.decks[":id"].$get({ param: { id: deckId } }),
+ apiClient.rpc.api.decks[":deckId"].study.$get({ param: { deckId } }),
+ ]);
- const deckData = await apiClient.handleResponse<{ deck: StudyDeck }>(
- deckRes,
- );
- const cardsData = await apiClient.handleResponse<{ cards: StudyCard[] }>(
- cardsRes,
- );
+ const deckData = await apiClient.handleResponse<{ deck: StudyDeck }>(
+ deckRes,
+ );
+ const cardsData = await apiClient.handleResponse<{
+ cards: StudyCard[];
+ }>(cardsRes);
- return {
- deck: deckData.deck,
- cards: shuffle(cardsData.cards),
- };
- },
+ return {
+ deck: deckData.deck,
+ cards: shuffle(cardsData.cards),
+ };
+ },
+ })),
);
diff --git a/src/client/atoms/utils.ts b/src/client/atoms/utils.ts
deleted file mode 100644
index e7af288..0000000
--- a/src/client/atoms/utils.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { atom, type Getter, type WritableAtom } from "jotai";
-
-// Symbol to identify reload action
-const RELOAD = Symbol("reload");
-
-/**
- * A WritableAtom that returns T (or Promise<T> before hydration) and accepts
- * an optional T value for hydration, or undefined to trigger reload.
- */
-export type ReloadableAtom<T> = WritableAtom<T | Promise<T>, [T?], void>;
-
-/**
- * Creates an async atom that can be reloaded by calling its setter.
- * Read the atom to get the data (suspends while loading).
- * Set the atom with no args to trigger a reload.
- * Set the atom with a value to hydrate (useful for testing).
- */
-export function createReloadableAtom<T>(
- getter: (get: Getter) => Promise<T>,
-): ReloadableAtom<T> {
- const refetchKeyAtom = atom(0);
- // Stores hydrated value - undefined means not hydrated
- const hydratedValueAtom = atom<{ value: T } | undefined>(undefined);
-
- return atom(
- // Not using async here - returns T synchronously when hydrated, Promise<T> when fetching
- (get): T | Promise<T> => {
- // Check for hydrated value first (sync path - avoids Suspense)
- const hydrated = get(hydratedValueAtom);
- if (hydrated !== undefined) {
- return hydrated.value;
- }
- // Async path - will trigger Suspense
- get(refetchKeyAtom);
- return getter(get);
- },
- (_get, set, action?: T | typeof RELOAD) => {
- if (action === undefined || action === RELOAD) {
- // Trigger reload: clear hydrated value and bump refetch key
- set(hydratedValueAtom, undefined);
- set(refetchKeyAtom, (k) => k + 1);
- } else {
- // Hydrate with value
- set(hydratedValueAtom, { value: action });
- }
- },
- );
-}
-
-// Track all atom family caches for test cleanup
-const atomFamilyCaches: Map<unknown, unknown>[] = [];
-
-/**
- * Creates a reloadable atom family for parameterized async data.
- * Each unique parameter gets its own cached atom with reload capability.
- */
-export function createReloadableAtomFamily<T, P extends string | number>(
- getter: (param: P, get: Getter) => Promise<T>,
-): (param: P) => ReloadableAtom<T> {
- const cache = new Map<P, ReloadableAtom<T>>();
- atomFamilyCaches.push(cache);
-
- return (param: P): ReloadableAtom<T> => {
- let reloadableAtom = cache.get(param);
- if (!reloadableAtom) {
- reloadableAtom = createReloadableAtom((get) => getter(param, get));
- cache.set(param, reloadableAtom);
- }
- return reloadableAtom;
- };
-}
-
-/**
- * Clears all atom family caches. Call this in test beforeEach/afterEach
- * to ensure tests don't share cached atoms.
- */
-export function clearAtomFamilyCaches() {
- for (const cache of atomFamilyCaches) {
- cache.clear();
- }
-}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index a1d296a..b9296f4 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,9 +1,19 @@
-import { StrictMode } from "react";
+import { Provider, useStore } from "jotai/react";
+import { useHydrateAtoms } from "jotai/react/utils";
+import { queryClientAtom } from "jotai-tanstack-query";
+import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { StoreInitializer } from "./components/StoreInitializer";
+import { queryClient } from "./queryClient";
import "./styles.css";
+function HydrateQueryClient({ children }: { children: ReactNode }) {
+ const store = useStore();
+ useHydrateAtoms([[queryClientAtom, queryClient]], { store });
+ return <>{children}</>;
+}
+
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
@@ -11,8 +21,12 @@ if (!rootElement) {
createRoot(rootElement).render(
<StrictMode>
- <StoreInitializer>
- <App />
- </StoreInitializer>
+ <Provider>
+ <HydrateQueryClient>
+ <StoreInitializer>
+ <App />
+ </StoreInitializer>
+ </HydrateQueryClient>
+ </Provider>
</StrictMode>,
);
diff --git a/src/client/pages/DeckCardsPage.test.tsx b/src/client/pages/DeckCardsPage.test.tsx
index d70da83..01a2420 100644
--- a/src/client/pages/DeckCardsPage.test.tsx
+++ b/src/client/pages/DeckCardsPage.test.tsx
@@ -1,20 +1,15 @@
/**
* @vitest-environment jsdom
*/
+import { QueryClient } from "@tanstack/query-core";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createStore, Provider } from "jotai";
+import { queryClientAtom } from "jotai-tanstack-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
-import {
- authLoadingAtom,
- type Card,
- cardsByDeckAtomFamily,
- type Deck,
- deckByIdAtomFamily,
-} from "../atoms";
-import { clearAtomFamilyCaches } from "../atoms/utils";
+import { authLoadingAtom, type Card, type Deck } from "../atoms";
import { DeckCardsPage } from "./DeckCardsPage";
const mockDeckGet = vi.fn();
@@ -63,6 +58,14 @@ vi.mock("../api/client", () => ({
},
}));
+// Mock queryClient module so pages use our test queryClient
+let testQueryClient: QueryClient;
+vi.mock("../queryClient", () => ({
+ get queryClient() {
+ return testQueryClient;
+ },
+}));
+
import { ApiClientError, apiClient } from "../api/client";
const mockDeck = {
@@ -184,17 +187,18 @@ function renderWithProviders({
const { hook } = memoryLocation({ path, static: true });
const store = createStore();
store.set(authLoadingAtom, false);
+ store.set(queryClientAtom, testQueryClient);
// Extract deckId from path
const deckIdMatch = path.match(/\/decks\/([^/]+)/);
const deckId = deckIdMatch?.[1] ?? "deck-1";
- // Hydrate atoms if initial data provided
+ // Seed query cache if initial data provided
if (initialDeck !== undefined) {
- store.set(deckByIdAtomFamily(deckId), initialDeck);
+ testQueryClient.setQueryData(["decks", deckId], initialDeck);
}
if (initialCards !== undefined) {
- store.set(cardsByDeckAtomFamily(deckId), initialCards);
+ testQueryClient.setQueryData(["decks", deckId, "cards"], initialCards);
}
return render(
@@ -211,6 +215,11 @@ function renderWithProviders({
describe("DeckCardsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
+ },
+ });
vi.mocked(apiClient.getTokens).mockReturnValue({
accessToken: "access-token",
refreshToken: "refresh-token",
@@ -238,7 +247,7 @@ describe("DeckCardsPage", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
- clearAtomFamilyCaches();
+ testQueryClient.clear();
});
it("renders back link and deck name", () => {
diff --git a/src/client/pages/DeckCardsPage.tsx b/src/client/pages/DeckCardsPage.tsx
index f7fd3b7..73a83ae 100644
--- a/src/client/pages/DeckCardsPage.tsx
+++ b/src/client/pages/DeckCardsPage.tsx
@@ -11,7 +11,7 @@ import {
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useAtomValue, useSetAtom } from "jotai";
+import { useAtomValue } from "jotai";
import {
Suspense,
useCallback,
@@ -19,7 +19,6 @@ import {
useMemo,
useRef,
useState,
- useTransition,
} from "react";
import { useDebouncedCallback } from "use-debounce";
import { Link, useParams } from "wouter";
@@ -32,6 +31,7 @@ import { EditNoteModal } from "../components/EditNoteModal";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { ImportNotesModal } from "../components/ImportNotesModal";
import { LoadingSpinner } from "../components/LoadingSpinner";
+import { queryClient } from "../queryClient";
/** Combined type for display: note group */
type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] };
@@ -173,7 +173,7 @@ function NoteGroupCard({
}
function DeckHeader({ deckId }: { deckId: string }) {
- const deck = useAtomValue(deckByIdAtomFamily(deckId));
+ const { data: deck } = useAtomValue(deckByIdAtomFamily(deckId));
return (
<div className="mb-8">
@@ -267,7 +267,7 @@ function CardList({
onDeleteNote: (noteId: string) => void;
onCreateNote: () => void;
}) {
- const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
+ const { data: cards } = useAtomValue(cardsByDeckAtomFamily(deckId));
const [currentPage, setCurrentPage] = useState(0);
// Group cards by note for display, applying search filter
@@ -414,7 +414,7 @@ function CardsContent({
onEditNote: (noteId: string) => void;
onDeleteNote: (noteId: string) => void;
}) {
- const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
+ const { data: cards } = useAtomValue(cardsByDeckAtomFamily(deckId));
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const debouncedSetQuery = useDebouncedCallback((value: string) => {
@@ -521,9 +521,6 @@ function CardsContent({
export function DeckCardsPage() {
const { deckId } = useParams<{ deckId: string }>();
- const [, startTransition] = useTransition();
-
- const reloadCards = useSetAtom(cardsByDeckAtomFamily(deckId || ""));
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
@@ -533,9 +530,7 @@ export function DeckCardsPage() {
const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null);
const handleCardMutation = () => {
- startTransition(() => {
- reloadCards();
- });
+ queryClient.invalidateQueries({ queryKey: ["decks", deckId, "cards"] });
};
if (!deckId) {
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index 903edb7..41f42fd 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -1,19 +1,14 @@
/**
* @vitest-environment jsdom
*/
+import { QueryClient } from "@tanstack/query-core";
import { cleanup, render, screen } from "@testing-library/react";
import { createStore, Provider } from "jotai";
+import { queryClientAtom } from "jotai-tanstack-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
-import {
- authLoadingAtom,
- type Card,
- cardsByDeckAtomFamily,
- type Deck,
- deckByIdAtomFamily,
-} from "../atoms";
-import { clearAtomFamilyCaches } from "../atoms/utils";
+import { authLoadingAtom, type Card, type Deck } from "../atoms";
import { DeckDetailPage } from "./DeckDetailPage";
const mockDeckGet = vi.fn();
@@ -58,6 +53,8 @@ vi.mock("../api/client", () => ({
import { apiClient } from "../api/client";
+let testQueryClient: QueryClient;
+
const mockDeck = {
id: "deck-1",
name: "Japanese Vocabulary",
@@ -127,17 +124,18 @@ function renderWithProviders({
const { hook } = memoryLocation({ path, static: true });
const store = createStore();
store.set(authLoadingAtom, false);
+ store.set(queryClientAtom, testQueryClient);
// Extract deckId from path
const deckIdMatch = path.match(/\/decks\/([^/]+)/);
const deckId = deckIdMatch?.[1] ?? "deck-1";
- // Hydrate atoms if initial data provided
+ // Seed query cache if initial data provided
if (initialDeck !== undefined) {
- store.set(deckByIdAtomFamily(deckId), initialDeck);
+ testQueryClient.setQueryData(["decks", deckId], initialDeck);
}
if (initialCards !== undefined) {
- store.set(cardsByDeckAtomFamily(deckId), initialCards);
+ testQueryClient.setQueryData(["decks", deckId, "cards"], initialCards);
}
return render(
@@ -154,6 +152,11 @@ function renderWithProviders({
describe("DeckDetailPage", () => {
beforeEach(() => {
vi.clearAllMocks();
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
+ },
+ });
vi.mocked(apiClient.getTokens).mockReturnValue({
accessToken: "access-token",
refreshToken: "refresh-token",
@@ -180,7 +183,7 @@ describe("DeckDetailPage", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
- clearAtomFamilyCaches();
+ testQueryClient.clear();
});
it("renders back link and deck name", () => {
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index d39f063..be4dc90 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -12,7 +12,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary";
import { LoadingSpinner } from "../components/LoadingSpinner";
function DeckHeader({ deckId }: { deckId: string }) {
- const deck = useAtomValue(deckByIdAtomFamily(deckId));
+ const { data: deck } = useAtomValue(deckByIdAtomFamily(deckId));
return (
<div className="mb-8">
@@ -25,7 +25,7 @@ function DeckHeader({ deckId }: { deckId: string }) {
}
function DeckStats({ deckId }: { deckId: string }) {
- const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
+ const { data: cards } = useAtomValue(cardsByDeckAtomFamily(deckId));
// Count cards due today
const now = new Date();
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index 8946fcf..8b17506 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -2,15 +2,16 @@
* @vitest-environment jsdom
*/
import "fake-indexeddb/auto";
+import { QueryClient } from "@tanstack/query-core";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createStore, Provider } from "jotai";
+import { queryClientAtom } from "jotai-tanstack-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { apiClient } from "../api/client";
-import { authLoadingAtom, type Deck, decksAtom } from "../atoms";
-import { clearAtomFamilyCaches } from "../atoms/utils";
+import { authLoadingAtom, type Deck } from "../atoms";
import { HomePage } from "./HomePage";
const mockDeckPut = vi.fn();
@@ -51,6 +52,14 @@ vi.mock("../api/client", () => ({
},
}));
+// Mock queryClient module so pages use our test queryClient
+let testQueryClient: QueryClient;
+vi.mock("../queryClient", () => ({
+ get queryClient() {
+ return testQueryClient;
+ },
+}));
+
// Mock fetch globally for Edit/Delete modals
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -109,10 +118,11 @@ function renderWithProviders({
const { hook } = memoryLocation({ path });
const store = createStore();
store.set(authLoadingAtom, false);
+ store.set(queryClientAtom, testQueryClient);
- // If initialDecks provided, hydrate the atom to skip Suspense
+ // If initialDecks provided, seed query cache to skip Suspense
if (initialDecks !== undefined) {
- store.set(decksAtom, initialDecks);
+ testQueryClient.setQueryData(["decks"], initialDecks);
}
return render(
@@ -127,7 +137,11 @@ function renderWithProviders({
describe("HomePage", () => {
beforeEach(() => {
vi.clearAllMocks();
- clearAtomFamilyCaches();
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
+ },
+ });
vi.mocked(apiClient.getTokens).mockReturnValue({
accessToken: "access-token",
refreshToken: "refresh-token",
@@ -152,7 +166,7 @@ describe("HomePage", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
- clearAtomFamilyCaches();
+ testQueryClient.clear();
});
it("renders page title and logout button", () => {
@@ -260,6 +274,12 @@ describe("HomePage", () => {
});
it("passes auth header when fetching decks", async () => {
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 0, retry: false },
+ },
+ });
+
vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
mockResponse({
ok: true,
diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index ad6ece4..d04ca08 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -7,7 +7,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useAtomValue, useSetAtom } from "jotai";
-import { Suspense, useState, useTransition } from "react";
+import { Suspense, useState } from "react";
import { Link } from "wouter";
import { type Deck, decksAtom, logoutAtom } from "../atoms";
import { CreateDeckModal } from "../components/CreateDeckModal";
@@ -17,6 +17,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { SyncButton } from "../components/SyncButton";
import { SyncStatusIndicator } from "../components/SyncStatusIndicator";
+import { queryClient } from "../queryClient";
function DeckList({
onEditDeck,
@@ -25,7 +26,7 @@ function DeckList({
onEditDeck: (deck: Deck) => void;
onDeleteDeck: (deck: Deck) => void;
}) {
- const decks = useAtomValue(decksAtom);
+ const { data: decks } = useAtomValue(decksAtom);
if (decks.length === 0) {
return (
@@ -113,17 +114,13 @@ function DeckList({
export function HomePage() {
const logout = useSetAtom(logoutAtom);
- const reloadDecks = useSetAtom(decksAtom);
- const [, startTransition] = useTransition();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingDeck, setEditingDeck] = useState<Deck | null>(null);
const [deletingDeck, setDeletingDeck] = useState<Deck | null>(null);
const handleDeckMutation = () => {
- startTransition(() => {
- reloadDecks();
- });
+ queryClient.invalidateQueries({ queryKey: ["decks"] });
};
return (
diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx
index 8bacd0f..ac68e35 100644
--- a/src/client/pages/NoteTypesPage.test.tsx
+++ b/src/client/pages/NoteTypesPage.test.tsx
@@ -2,14 +2,15 @@
* @vitest-environment jsdom
*/
import "fake-indexeddb/auto";
+import { QueryClient } from "@tanstack/query-core";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createStore, Provider } from "jotai";
+import { queryClientAtom } from "jotai-tanstack-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
-import { authLoadingAtom, type NoteType, noteTypesAtom } from "../atoms";
-import { clearAtomFamilyCaches } from "../atoms/utils";
+import { authLoadingAtom, type NoteType } from "../atoms";
import { NoteTypesPage } from "./NoteTypesPage";
interface RenderOptions {
@@ -59,6 +60,14 @@ vi.mock("../api/client", () => ({
},
}));
+// Mock queryClient module so pages use our test queryClient
+let testQueryClient: QueryClient;
+vi.mock("../queryClient", () => ({
+ get queryClient() {
+ return testQueryClient;
+ },
+}));
+
import { ApiClientError, apiClient } from "../api/client";
const mockNoteTypes = [
@@ -89,10 +98,11 @@ function renderWithProviders({
const { hook } = memoryLocation({ path });
const store = createStore();
store.set(authLoadingAtom, false);
+ store.set(queryClientAtom, testQueryClient);
- // Hydrate atom if initial data provided
+ // Seed query cache if initial data provided
if (initialNoteTypes !== undefined) {
- store.set(noteTypesAtom, initialNoteTypes);
+ testQueryClient.setQueryData(["noteTypes"], initialNoteTypes);
}
return render(
@@ -107,6 +117,11 @@ function renderWithProviders({
describe("NoteTypesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
+ },
+ });
vi.mocked(apiClient.getTokens).mockReturnValue({
accessToken: "access-token",
refreshToken: "refresh-token",
@@ -138,7 +153,7 @@ describe("NoteTypesPage", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
- clearAtomFamilyCaches();
+ testQueryClient.clear();
});
it("renders page title and back button", () => {
diff --git a/src/client/pages/NoteTypesPage.tsx b/src/client/pages/NoteTypesPage.tsx
index 8e742a7..d838e5b 100644
--- a/src/client/pages/NoteTypesPage.tsx
+++ b/src/client/pages/NoteTypesPage.tsx
@@ -7,8 +7,8 @@ import {
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useAtomValue, useSetAtom } from "jotai";
-import { Suspense, useState, useTransition } from "react";
+import { useAtomValue } from "jotai";
+import { Suspense, useState } from "react";
import { Link } from "wouter";
import { type NoteType, noteTypesAtom } from "../atoms";
import { CreateNoteTypeModal } from "../components/CreateNoteTypeModal";
@@ -16,6 +16,7 @@ import { DeleteNoteTypeModal } from "../components/DeleteNoteTypeModal";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { NoteTypeEditor } from "../components/NoteTypeEditor";
+import { queryClient } from "../queryClient";
function NoteTypeList({
onEditNoteType,
@@ -24,7 +25,7 @@ function NoteTypeList({
onEditNoteType: (id: string) => void;
onDeleteNoteType: (noteType: NoteType) => void;
}) {
- const noteTypes = useAtomValue(noteTypesAtom);
+ const { data: noteTypes } = useAtomValue(noteTypesAtom);
if (noteTypes.length === 0) {
return (
@@ -114,9 +115,6 @@ function NoteTypeList({
}
export function NoteTypesPage() {
- const reloadNoteTypes = useSetAtom(noteTypesAtom);
- const [, startTransition] = useTransition();
-
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingNoteTypeId, setEditingNoteTypeId] = useState<string | null>(
null,
@@ -126,9 +124,7 @@ export function NoteTypesPage() {
);
const handleNoteTypeMutation = () => {
- startTransition(() => {
- reloadNoteTypes();
- });
+ queryClient.invalidateQueries({ queryKey: ["noteTypes"] });
};
return (
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx
index a366f35..dfadea9 100644
--- a/src/client/pages/StudyPage.test.tsx
+++ b/src/client/pages/StudyPage.test.tsx
@@ -1,19 +1,15 @@
/**
* @vitest-environment jsdom
*/
+import { QueryClient } from "@tanstack/query-core";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createStore, Provider } from "jotai";
+import { queryClientAtom } from "jotai-tanstack-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
-import {
- authLoadingAtom,
- type StudyCard,
- type StudyData,
- studyDataAtomFamily,
-} from "../atoms";
-import { clearAtomFamilyCaches } from "../atoms/utils";
+import { authLoadingAtom, type StudyCard, type StudyData } from "../atoms";
import { StudyPage } from "./StudyPage";
interface RenderOptions {
@@ -72,6 +68,8 @@ vi.mock("../api/client", () => ({
import { ApiClientError, apiClient } from "../api/client";
+let testQueryClient: QueryClient;
+
const mockDeck = {
id: "deck-1",
name: "Japanese Vocabulary",
@@ -121,14 +119,15 @@ function renderWithProviders({
const { hook } = memoryLocation({ path, static: true });
const store = createStore();
store.set(authLoadingAtom, false);
+ store.set(queryClientAtom, testQueryClient);
// Extract deckId from path
const deckIdMatch = path.match(/\/decks\/([^/]+)/);
const deckId = deckIdMatch?.[1] ?? "deck-1";
- // Hydrate atom if initial data provided
+ // Seed query cache if initial data provided
if (initialStudyData !== undefined) {
- store.set(studyDataAtomFamily(deckId), initialStudyData);
+ testQueryClient.setQueryData(["decks", deckId, "study"], initialStudyData);
}
return render(
@@ -145,6 +144,11 @@ function renderWithProviders({
describe("StudyPage", () => {
beforeEach(() => {
vi.clearAllMocks();
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
+ },
+ });
vi.mocked(apiClient.getTokens).mockReturnValue({
accessToken: "access-token",
refreshToken: "refresh-token",
@@ -161,7 +165,7 @@ describe("StudyPage", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
- clearAtomFamilyCaches();
+ testQueryClient.clear();
});
describe("Loading and Initial State", () => {
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx
index cec11d3..dd82b27 100644
--- a/src/client/pages/StudyPage.tsx
+++ b/src/client/pages/StudyPage.tsx
@@ -37,7 +37,9 @@ const RatingStyles: Record<Rating, string> = {
};
function StudySession({ deckId }: { deckId: string }) {
- const { deck, cards } = useAtomValue(studyDataAtomFamily(deckId));
+ const {
+ data: { deck, cards },
+ } = useAtomValue(studyDataAtomFamily(deckId));
// Session state (kept as useState - transient UI state)
const [currentIndex, setCurrentIndex] = useState(0);
diff --git a/src/client/queryClient.ts b/src/client/queryClient.ts
new file mode 100644
index 0000000..8743543
--- /dev/null
+++ b/src/client/queryClient.ts
@@ -0,0 +1,10 @@
+import { QueryClient } from "@tanstack/query-core";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 0,
+ retry: false,
+ },
+ },
+});