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

185 lines
10 KiB
Markdown

# Consumer data caching (TanStack Query)
The consumer app (`app/(consumer)/**`, guest and host pages alike) fetches
and caches server data through [TanStack Query](https://tanstack.com/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`](./caching-strategy.md) — read that first when
changing SSR, `revalidate`, or public discovery endpoints.
## Core pieces
- **`lib/queryClient.ts`** — `makeQueryClient()` 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`](./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 `onSuccess``AuthContext.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:
```ts
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.