From f8e4be9b36a16969ac53bd9ce12ce8064be10196 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 4 Jan 2026 17:43:59 +0900 Subject: refactor(client): migrate state management from React Context to Jotai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace AuthProvider and SyncProvider with Jotai atoms for more granular state management and better performance. This migration: - Creates atoms for auth, sync, decks, cards, noteTypes, and study state - Uses atomFamily for parameterized state (e.g., cards by deckId) - Introduces StoreInitializer component for subscription initialization - Updates all components and pages to use useAtomValue/useSetAtom - Updates all tests to use Jotai Provider with createStore pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/atoms/utils.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/client/atoms/utils.ts (limited to 'src/client/atoms/utils.ts') diff --git a/src/client/atoms/utils.ts b/src/client/atoms/utils.ts new file mode 100644 index 0000000..e7af288 --- /dev/null +++ b/src/client/atoms/utils.ts @@ -0,0 +1,81 @@ +import { atom, type Getter, type WritableAtom } from "jotai"; + +// Symbol to identify reload action +const RELOAD = Symbol("reload"); + +/** + * A WritableAtom that returns T (or Promise before hydration) and accepts + * an optional T value for hydration, or undefined to trigger reload. + */ +export type ReloadableAtom = WritableAtom, [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( + getter: (get: Getter) => Promise, +): ReloadableAtom { + 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 when fetching + (get): T | Promise => { + // 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[] = []; + +/** + * Creates a reloadable atom family for parameterized async data. + * Each unique parameter gets its own cached atom with reload capability. + */ +export function createReloadableAtomFamily( + getter: (param: P, get: Getter) => Promise, +): (param: P) => ReloadableAtom { + const cache = new Map>(); + atomFamilyCaches.push(cache); + + return (param: P): ReloadableAtom => { + 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(); + } +} -- cgit v1.2.3-70-g09d2