Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
185 lines
10 KiB
Markdown
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.
|