admin/docs/consumer-caching.md
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

10 KiB

Consumer data caching (TanStack Query)

The consumer app (app/(consumer)/**, guest and host pages alike) fetches and caches server data through TanStack Query v5, not ad-hoc useState + useEffect. This replaced a per-page pattern of manual loading/error state and full-list reloads after every mutation. Read this before adding a new fetch, list, or mutation under (consumer).

Full-stack caching (guest ISR vs logged-in dynamic, Next Data Cache, Redis): see caching-strategy.md — read that first when changing SSR, revalidate, or public discovery endpoints.

Core pieces

  • lib/queryClient.tsmakeQueryClient() builds the QueryClient with the project defaults (staleTime: 30s, gcTime: 5min, refetchOnWindowFocus/Reconnect: true, retry: 1). getQueryClient() is the browser-side singleton accessor: on the server it always returns a fresh client (no cross-request state leak); in the browser it memoizes one instance in a module-level variable so client-side navigations share a single cache instead of rebuilding it per page.
  • app/providers.tsx — wraps the app in QueryClientProvider, seeded from getQueryClient() via useState(() => getQueryClient()) (so the provider itself doesn't recreate the client on re-render).
  • queries/consumerKeys.ts — the single query-key factory. Every query key used by consumer code should come from here, not be hand-written inline — it's what makes cache patches and invalidations from one file reliably reach queries defined in another. Read the file's own comment about following() being a prefix of followingCount()/isFollowing() before adding a new key in that shape; pass { exact: true } when you mean only the list itself.
  • queries/unwrapService.ts — every service function in this codebase returns a ServiceResult<T> ({ ok, data } or { ok: false, error }), but TanStack Query wants a queryFn that either resolves or throws. unwrapService(await SOME_SERVICE_CALL(...)) bridges the two: pass { errorMode: 'parent' } to the service call so its own error handling doesn't swallow the failure before unwrapService gets to throw it.

Query/mutation hook layer (queries/consumer/*.ts)

One file per data domain (useWalletQuery.ts, useBookingQueries.ts, useFollowingQueries.ts, …), each exporting plain hooks — no classes, no shared base hook. Conventions to follow:

  • Accept an enabled = true parameter on read hooks so callers can gate a query behind auth state or a tab being active, without duplicating the query definition.
  • Call unwrapService(await SOME_SERVICE(..., { errorMode: 'parent' })) inside queryFn for anything that returns a ServiceResult; pass through signal when the underlying service call accepts one, so navigating away cancels the in-flight request.
  • Mutations live next to the queries they affect (e.g. useCancelBookingMutation.ts, useHostedEventMutations.ts), not inside page components. A mutation's onSuccess should patch the cache directly with queryClient.setQueryData(key, updater) for the specific list/record it changed, rather than a blanket invalidateQueries + refetch — this keeps the UI from flashing a full reload and preserves scroll position. Only fall back to invalidateQueries for data the mutation doesn't have the fresh shape of in hand (e.g. cancelling a booking invalidates wallet() because the refund amount isn't known client-side).
  • Because mutation hooks patch the shared cache directly, list-item components (BookingListItem, HostedEventListItem, …) don't need onChanged/onDeleted callback props threaded up to the page — each row calls its own mutation hook and the shared list query updates itself.

Paginated lists: always useInfiniteQuery, never manual page-accumulation

Every paginated list (following/followers, discovery events, chat messages) uses useInfiniteQuery, even the ones with an unusual pagination shape (chat — see below). Do not build pagination by hand on top of a plain useQuery (fetch page 1 in queryFn, then setQueryData to prepend further pages on "load more"). That shape is a live bug: useQuery's queryFn has no idea a second page was ever merged in, so any background refetch — window refocus, reconnect, or just the query going stale — calls queryFn again, which re-fetches only page 1 and silently overwrites the whole accumulated list. useInfiniteQuery doesn't have this problem: a refetch replays every page currently in data.pages, each with the exact pageParam it was originally fetched with, so accumulated history survives a background refresh.

Chat messages (features/chat/useChatThread.ts) are the one non-obvious case: the API always returns the newest window first with a stable before cursor for going further back, so page 0 (fetched with pageParam: undefined) is the newest page, and pages fetched via fetchNextPage() afterwards are progressively older. Rendering therefore reverses the pages array before flattening ([...pages].reverse(), each page's own items already oldest→newest) to get a chronological thread. New messages — from SEND_MESSAGE or the chat socket — get appended into pages[0] (the newest page), never onto a flat array, via a shared appendMessageToCache helper.

fetchNextPage() swallows errors by default — the underlying promise is .catch(noop)'d unless you pass { throwOnError: true }. Every "load more" handler in this codebase that wraps fetchNextPage() in a try/catch (ConsumerHomeDiscovery.tsx, useChatThread.ts) passes throwOnError: true, otherwise the catch block is dead code and load-more failures fail silently. following/followers don't need this because they don't wrap the call in a try/catch — they read the error reactively off query.error/query.isError instead, which TanStack still sets correctly even when the promise itself is swallowed.

Seeding the cache from SSR data

ConsumerHomeDiscovery.tsx receives server-rendered initialCategories / initialCities / initialHomeFeed as props (from app/(consumer)/page.tsx, ISR revalidate = 60 plus next: { revalidate } on lib/seo/serverApi.ts fetches — see caching-strategy.md §4). Those props seed the query cache's initialData / placeholderData instead of the client re-fetching on mount — but only for the default/no-filter selection, since that's the only case the SSR fetch matches. Changing a filter creates a new query key with no initialData and fetches normally. If you add a new SSR-seeded query, gate its initialData the same way (isDefaultSelection && ...) or you'll seed stale data for a filter combination the server never actually fetched.

Bottom-nav tab keep-alive (UI layer)

TanStack Query caches data across tab switches; this layer caches mounted page trees for the four bottom-nav roots (/, /my-events, /chats, /profile).

  • components/consumer/ConsumerBottomNavKeepAlive.tsx — lazy-mounts each tab once, keeps inactive panels in the DOM (hidden + inert), and gives each tab its own overflow-y-auto scroller (data-consumer-tab-panel="{tab}"). Each panel also provides ConsumerTabActivationContext: visible panels report active; hidden panels report inactive; pages outside this keep-alive shell default to active.
  • lib/consumerTabKeepAlive.ts — tracks which tabs have been mounted so app/(consumer)/loading.tsx can skip the route skeleton on revisit.
  • app/(consumer)/layout.tsx — tab roots render inside the keep-alive shell; nested routes (e.g. /profile/wallet, /category/...) render in a stack layer above the hidden panels. Event soft-nav (/e/...) still uses the existing overlay pin logic.
  • Scroll — per-tab scroll lives on each panel scroller (lib/consumerTabScroll.ts); pull-to-refresh on home reads the active panel via ConsumerTabPanelScrollContext.

Keep-alive preserves component state; it must not keep expensive background work active. Root-tab queries combine their existing eligibility condition with useIsConsumerTabActive(). The Chats root also pauses its page-level socket subscription, and Home disables pull-to-refresh listeners while hidden. The consumer-shell socket transport and global unread badge remain active by design. When a tab becomes visible again, its cached UI is immediate and stale enabled queries revalidate normally.

Nested discovery URLs under Home (/category/..., /city/...) are not tab roots: they use the stack layer while the home panel stays mounted underneath.

Session data: AuthContext.user vs. the me() query cache

AuthContext.user (bootstrapped synchronously from localStorage, no network call) and consumerKeys.me() (the live useMeQuery() cache, services/users.ts's GET_ME/PATCH_ME) are two copies of overlapping profile fields (name, avatar, bio, city, default address). Don't hand-sync them from a mutation's onSuccessAuthContext.tsx subscribes to the query cache once, at the provider level, and mirrors me() into user automatically whenever that cache changes (setQueryData, invalidate-then-refetch, background refetch — anything). A mutation only needs to call queryClient.setQueryData(consumerKeys.me(), result.data) (or invalidateQueries); the profile fields on useAuth().user update on their own. This also means logout() clearing the whole cache (getQueryClient().clear()) can't leave a previous user's profile fields sitting in AuthContext.user after the redirect.

Testing

Any component or hook under test that calls useQuery/useMutation/ useQueryClient needs a QueryClientProvider in the test tree — there is no ambient one. The convention across this codebase's tests (BookingActions.test.tsx, OrganizerFollowButton.test.tsx, useProfileAccount.test.ts, …) is a small local helper:

const renderWithQuery = (ui: React.ReactElement) => {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  })

  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}

(renderHook needs the same client, passed as its wrapper option, when testing a hook directly instead of a component.) Turn retry off in the test client — otherwise a deliberately-failing mock service call retries before the query settles into its error state, and waitFor assertions on error UI become flaky/slow.