perf: optimize web data resources
This commit is contained in:
@@ -48,10 +48,61 @@ Login and public marketing/legal pages are not part of the authenticated app she
|
||||
- Shared request behavior lives in `web/src/lib/api-client.ts`.
|
||||
- Auth/session behavior lives in `web/src/lib/auth.ts`.
|
||||
- Business API functions live in `web/src/lib/api.ts`.
|
||||
- Shared authenticated read caching lives in `web/src/lib/data-client.ts` and `web/src/lib/resources.ts`.
|
||||
- Components must call typed API helper functions, not inline `fetch('/api/...')`.
|
||||
- Components that need profile, points, packages, history, notifications, or unread-count data should use the resource hooks/functions from `web/src/lib/resources.ts` instead of starting their own duplicate GET lifecycle.
|
||||
- Dashboard-visible user, points, notification, and history data must come from the backend. Do not hardcode those values.
|
||||
- Production API host is `https://api.meeyao.com`; local dev should use same-origin `/api` and the Vite proxy.
|
||||
|
||||
### Authenticated Data Resource Pattern
|
||||
|
||||
Use this pattern for backend reads that are reused across authenticated pages:
|
||||
|
||||
```typescript
|
||||
// lib/api.ts: transport-only business API
|
||||
export function getPointsBalance(): Promise<PointsBalance> {
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||
}
|
||||
|
||||
// lib/resources.ts: cache policy + hook/function surface
|
||||
export function usePoints() {
|
||||
return useResource({
|
||||
key: pointsBalanceKey,
|
||||
ttlMs: 60_000,
|
||||
fetcher: getPointsBalance,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Resource contracts:
|
||||
|
||||
- `lib/api.ts` remains transport-only: no per-endpoint ad hoc memory cache there.
|
||||
- `lib/resources.ts` owns resource keys, TTLs, in-flight dedupe, stale-while-revalidate behavior, prefetch, and mutation invalidation.
|
||||
- `clearAuth()` must clear the shared data cache so authenticated data cannot leak across users.
|
||||
- Resource hooks must support disabled/optional keys for pages where an id may be absent; do not create a fetcher that intentionally rejects during normal render.
|
||||
- Active hooks must refetch after invalidation when they still need the resource.
|
||||
|
||||
Invalidation matrix:
|
||||
|
||||
- Profile, avatar, or settings write -> set the profile resource with the returned backend profile.
|
||||
- Divination run or follow-up completion -> invalidate points and the relevant history list/thread resources.
|
||||
- Notification mark-read -> patch the notification list and decrement unread count when the item changes from unread to read.
|
||||
- Mark-all-notifications-read -> patch the notification list and set unread count to zero.
|
||||
- Logout, expired refresh, or invalid auth -> clear auth and clear all resource data.
|
||||
|
||||
Wrong vs correct:
|
||||
|
||||
```typescript
|
||||
// Wrong: every page starts an independent duplicate GET.
|
||||
useEffect(() => {
|
||||
getUserProfile().then(setProfile);
|
||||
}, []);
|
||||
|
||||
// Correct: subscribe to the shared profile resource.
|
||||
const profileState = useProfile();
|
||||
```
|
||||
|
||||
## Layout Rules
|
||||
|
||||
- Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/spec/web/index.md", "reason": "Verify request behavior, authenticated routing, and responsive browser checks."}
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app architecture, API, auth, and performance-sensitive layout rules."}
|
||||
@@ -0,0 +1,82 @@
|
||||
# Audit and optimize web performance
|
||||
|
||||
## Goal
|
||||
|
||||
Comprehensively audit and refactor the web frontend data interaction layer while preserving the existing UI. The refactor should reduce repeated HTTP requests, hide US-backend latency where safe, centralize cache/invalidation behavior, and make authenticated page transitions feel faster in both perceived and actual request-count terms.
|
||||
|
||||
## What I already know
|
||||
|
||||
* Backend is hosted in the US, so users far away experience noticeable request latency.
|
||||
* User has observed repeated HTTP requests and avoidable data-loading waits.
|
||||
* Web stack is Astro 6 + React 19 + React Router DOM under `web/`.
|
||||
* Auth state lives in `web/src/lib/auth.ts`; business APIs live in `web/src/lib/api.ts`.
|
||||
* Existing `getPointsBalance()` has a 1-minute in-memory TTL cache, but many other stable reads do not.
|
||||
* Early code scan found repeated `getUserProfile()` calls across `AppShell`, `LoginForm`, `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage`.
|
||||
* `AppShell` already loads profile globally and exposes `UserSettingsContext`; some pages still refetch profile instead of using that context.
|
||||
* Auth refresh now has single-flight behavior from the previous fix, which helps avoid concurrent refresh waste.
|
||||
|
||||
## Assumptions
|
||||
|
||||
* Performance improvements should preserve backend source of truth and auth safety.
|
||||
* Cached authenticated data must be invalidated after writes that change it.
|
||||
* We should prefer a local, typed data-client/resource layer over adding a large state/query dependency unless audit shows the custom approach is too risky.
|
||||
* Optimizations should be observable through request count reduction and improved perceived loading behavior.
|
||||
|
||||
## Requirements
|
||||
|
||||
* Audit all authenticated web pages for duplicate or avoidable requests.
|
||||
* Define and implement a frontend data layer with caching, in-flight dedupe, stale-while-revalidate, prefetch, and explicit invalidation.
|
||||
* Reuse AppShell-loaded profile/settings where possible instead of refetching.
|
||||
* Add cache invalidation for profile/settings/points changes.
|
||||
* Keep UI presentation unchanged at rest; only data source and loading/refresh behavior should change.
|
||||
* Avoid stale or unsafe auth behavior: 401 and refresh failures must still clear auth and redirect.
|
||||
* Improve perceived performance with cached data, route-level reuse, and fewer full-page loading waits.
|
||||
* Document verification with before/after request counts for key flows.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* [x] Request audit lists endpoints, call sites, duplicate patterns, and priority.
|
||||
* [x] Refactor plan defines target data architecture, resource cache policies, invalidation rules, and phased rollout.
|
||||
* [x] Data client/resource layer is implemented without changing the visible UI design.
|
||||
* [x] Dashboard → settings/profile/general/divination navigation avoids duplicate profile requests where safe.
|
||||
* [x] Points balance requests are deduped in-flight and cached/invalidated intentionally.
|
||||
* [x] Stable package/config-style reads are cached or intentionally left uncached with rationale.
|
||||
* [x] Authenticated pages keep correct behavior after writes and after 401 responses.
|
||||
* [ ] Browser verification captures representative flows under mobile and desktop viewports.
|
||||
* [x] `git diff --check` passes; build/typecheck status documented.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
* PRD updated with final implementation notes.
|
||||
* Targeted code changes committed.
|
||||
* Performance audit and refactor plan recorded in the task directory.
|
||||
* Any reusable conventions captured in `.trellis/spec/web/index.md` or relevant spec.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
* Backend endpoint redesign.
|
||||
* CDN/edge deployment changes.
|
||||
* Offline mode.
|
||||
* Large dependency adoption unless explicitly approved after audit.
|
||||
* Visual redesign of existing pages.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
* Follow `.trellis/spec/web/index.md`.
|
||||
* All API paths remain in `web/src/lib/api-routes.ts`.
|
||||
* Components should call typed API helpers from `web/src/lib/api.ts`, not inline `fetch('/api/...')`.
|
||||
* Target refactor plan: `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md`.
|
||||
* Request audit: `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md`.
|
||||
* Current build is blocked by existing Astro adapter configuration (`NoAdapterInstalled`) and should be documented unless fixed in a separate task.
|
||||
|
||||
## Implementation Notes - 2026-05-10
|
||||
|
||||
* Added `web/src/lib/data-client.ts`, a typed in-memory query cache with TTL, in-flight request dedupe, `peek`, `set`, prefix `invalidate`, `prefetch`, `subscribe`, and `clearAll`.
|
||||
* Added `web/src/lib/resources.ts` for profile, points, packages, history list/thread/summary, notifications list, and unread-count cache policy plus React resource hooks.
|
||||
* Moved points TTL behavior out of `api.ts`; `getPointsBalance()` is now transport-only and points caching lives in the resource layer.
|
||||
* AppShell now loads profile through the profile resource, subscribes to profile cache changes, clears data cache via `clearAuth()`, prefetches cheap dashboard data after auth, and prefetches route resources on nav hover/focus.
|
||||
* Settings, general settings, profile detail, dashboard, store, history list, notifications, manual/auto divination, divination processing, result, and follow-up pages now reuse resource caches instead of owning duplicate GET lifecycles.
|
||||
* Mutations patch or invalidate shared cache: profile/settings/avatar set profile; notification read/all-read patch list and unread count; divination/follow-up completion invalidates points and history.
|
||||
* Quality check fixed two post-implementation issues: invalidated active resources now refetch instead of staying empty/stale, and `useHistoryThread(undefined)` no longer emits a doomed request when result pages rely on router/session state.
|
||||
* Verification: `npm exec astro sync` passed; `git diff --check` passed; `npm run build` remains blocked by existing Astro `NoAdapterInstalled`; temporary `tsc --noEmit` check reports existing `DashboardApp.tsx` translation-map typing errors after refactor-specific type issues were fixed.
|
||||
* Browser verification note: the in-app browser was switched to a visible 390x844 mobile viewport with the dev server on `127.0.0.1:4322`, but the automation surface only exposed the in-app browser toolbar during this run, so request-count capture still needs a follow-up pass with a usable page automation surface.
|
||||
@@ -0,0 +1,383 @@
|
||||
# Web data interaction refactor plan
|
||||
|
||||
## Executive summary
|
||||
|
||||
The current web app has solid typed API helpers, but it does not yet have an application data layer. Most pages call backend endpoints directly inside local `useEffect` blocks, so route changes repeatedly reload the same user/profile/points/history data. This is especially visible when the backend is far away because every avoidable request becomes a user-visible wait.
|
||||
|
||||
The recommended refactor is to keep the UI unchanged and introduce a lightweight, project-owned data layer that centralizes:
|
||||
|
||||
* request in-flight dedupe
|
||||
* TTL cache and stale-while-revalidate behavior
|
||||
* explicit invalidation after writes
|
||||
* route-level prefetch
|
||||
* shared resource state for pages
|
||||
* optimistic local updates where safe
|
||||
|
||||
Do not start by sprinkling `localStorage` or ad hoc memoization into components. That would reduce some requests but preserve the deeper problem: every component still owns its own data lifecycle.
|
||||
|
||||
## Current architecture diagnosis
|
||||
|
||||
### 1. API helpers are typed, but not stateful
|
||||
|
||||
`web/src/lib/api.ts` provides typed functions such as `getUserProfile`, `getPointsBalance`, `getAgentHistory`, and `getNotifications`. Except for points, those functions always hit the backend.
|
||||
|
||||
This is clean as a transport layer, but incomplete as a frontend data layer.
|
||||
|
||||
### 2. Components own network lifecycle
|
||||
|
||||
Examples:
|
||||
|
||||
* `AppShell` fetches profile globally, but `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage` refetch profile.
|
||||
* `Dashboard` fetches points/history/unread count on mount.
|
||||
* `HistoryListPage` fetches history again even if dashboard just fetched it.
|
||||
* `StorePage` fetches packages on mount even though packages are stable config-style data.
|
||||
* `NotificationPage` owns its own list and mark-read updates without updating dashboard unread-count cache.
|
||||
|
||||
Each page is locally correct, but globally wasteful.
|
||||
|
||||
### 3. Loading states are page-local
|
||||
|
||||
Because data state is local to each page, the UI often transitions from a populated page to a blank/loading page even if relevant data was just fetched elsewhere. This makes high-latency backend access feel worse than it needs to.
|
||||
|
||||
### 4. Mutations do not have a shared invalidation model
|
||||
|
||||
Examples:
|
||||
|
||||
* `updateUserSettings` returns updated profile, but only the calling component updates local state.
|
||||
* `updateUserProfile` / `uploadAvatar` can leave `AppShell` profile stale unless context is manually updated.
|
||||
* `markNotificationRead` updates the notification page list, but dashboard unread count is not centrally invalidated.
|
||||
* `enqueueDivinationRun` should invalidate points and history after success, but that is not represented in a central policy.
|
||||
|
||||
## Target architecture
|
||||
|
||||
### Layer 1: transport stays simple
|
||||
|
||||
Keep `authFetch`, `authFetchRaw`, `apiUrl`, `api-routes`, and RFC7807 parsing as the low-level transport boundary.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* auth refresh and 401 handling
|
||||
* JSON request/response parsing
|
||||
* SSE raw response support
|
||||
* no UI cache knowledge
|
||||
|
||||
### Layer 2: API functions remain typed commands
|
||||
|
||||
Keep `web/src/lib/api.ts` as the typed backend API command layer.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* endpoint-specific payload construction
|
||||
* response types
|
||||
* backend data mapping helpers
|
||||
* no React hooks
|
||||
|
||||
Change:
|
||||
|
||||
* Remove ad hoc points-only cache from this file.
|
||||
* Move caching into a dedicated data/cache layer.
|
||||
|
||||
### Layer 3: new app data client
|
||||
|
||||
Add `web/src/lib/data-client.ts` or `web/src/lib/query-client.ts`.
|
||||
|
||||
Core capabilities:
|
||||
|
||||
```ts
|
||||
type CacheKey = readonly string[];
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
promise?: Promise<T>;
|
||||
}
|
||||
|
||||
interface QueryOptions<T> {
|
||||
key: CacheKey;
|
||||
ttlMs: number;
|
||||
fetcher: () => Promise<T>;
|
||||
staleWhileRevalidate?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Required operations:
|
||||
|
||||
* `query<T>(options): Promise<T>`: returns fresh data, dedupes in-flight request.
|
||||
* `peek<T>(key): T | undefined`: synchronous read for instant UI seeding.
|
||||
* `set<T>(key, data, ttlMs)`: update cache after mutations.
|
||||
* `invalidate(keyPrefix)`: remove or mark stale.
|
||||
* `prefetch(options)`: warm cache without forcing UI loading.
|
||||
* `clearAll()`: call on logout or terminal auth failure.
|
||||
|
||||
Why local helper first:
|
||||
|
||||
* Project currently has no query library.
|
||||
* The data model is small enough to solve with a typed helper.
|
||||
* Avoid adding dependency and architecture churn before measuring gains.
|
||||
|
||||
Future option:
|
||||
|
||||
* If data flows grow, migrate the same key model to TanStack Query later. The refactor should name cache keys and invalidation policies in a way that would map cleanly.
|
||||
|
||||
### Layer 4: resource-specific services
|
||||
|
||||
Add resource wrappers, for example:
|
||||
|
||||
* `web/src/lib/resources/profile.ts`
|
||||
* `web/src/lib/resources/points.ts`
|
||||
* `web/src/lib/resources/store.ts`
|
||||
* `web/src/lib/resources/history.ts`
|
||||
* `web/src/lib/resources/notifications.ts`
|
||||
|
||||
Each resource owns:
|
||||
|
||||
* cache key
|
||||
* TTL
|
||||
* fetch function
|
||||
* invalidation after writes
|
||||
* local patch helpers
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
export const profileKey = ['profile'] as const;
|
||||
|
||||
export function getProfileResource() {
|
||||
return query({
|
||||
key: profileKey,
|
||||
ttlMs: 5 * 60_000,
|
||||
fetcher: getUserProfile,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProfileResource(input: UpdateProfileRequest) {
|
||||
const updated = await updateUserProfile(input);
|
||||
set(profileKey, updated, PROFILE_TTL);
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 5: React provider and hooks
|
||||
|
||||
Add `AppDataProvider` under the authenticated app shell.
|
||||
|
||||
Hooks:
|
||||
|
||||
* `useResource(key, loader, options)`
|
||||
* `useProfile()`
|
||||
* `usePoints()`
|
||||
* `useHistorySummary()`
|
||||
* `useHistoryThread(threadId)`
|
||||
* `useNotifications(locale)`
|
||||
* `useUnreadCount()`
|
||||
|
||||
Hook behavior:
|
||||
|
||||
* seed UI from `peek()`
|
||||
* return cached data immediately when available
|
||||
* refresh in background when stale
|
||||
* expose `{ data, loading, refreshing, error, reload }`
|
||||
* never bypass auth 401 behavior
|
||||
|
||||
This keeps UI unchanged: pages still render the same cards/lists/forms, but data source shifts from local `useEffect` to shared hooks.
|
||||
|
||||
## Cache policy by data domain
|
||||
|
||||
| Domain | Key | TTL | Stale UI allowed | Invalidate on |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| Auth session | localStorage `meeyao_auth` | token expiry | no | logout, 401 refresh failure |
|
||||
| Profile/settings | `['profile']` | 5 min | yes | profile update, settings update, avatar upload, logout |
|
||||
| Points balance | `['points', 'balance']` | 30-60 sec | yes | divination run accepted/success, purchase, manual reload, logout |
|
||||
| Packages | `['points', 'packages']` | 30 min | yes | app reload, explicit admin/package changes if added later |
|
||||
| Dashboard history summary | `['history', 'summary']` | 60 sec | yes | divination run success, follow-up success, session delete |
|
||||
| History full list | `['history', 'list']` | 60 sec | yes | divination run success, follow-up success, session delete |
|
||||
| History thread | `['history', 'thread', threadId]` | 2-5 min | yes | follow-up success for same thread, session delete |
|
||||
| Notifications list | `['notifications', locale]` | 60 sec | yes | mark read/all read, new notification polling if added |
|
||||
| Unread count | `['notifications', 'unread-count']` | 30 sec | yes | mark read/all read, notification list refresh |
|
||||
|
||||
## Display-state model
|
||||
|
||||
Every page should distinguish:
|
||||
|
||||
* `loading`: no cached data yet and initial request is pending
|
||||
* `refreshing`: cached data is visible while background request updates it
|
||||
* `error`: no usable data, or background refresh failed
|
||||
* `stale`: data is visible but may be older than TTL
|
||||
|
||||
UI stays visually the same, but behavior improves:
|
||||
|
||||
* If cached data exists, do not show full-page loading.
|
||||
* Use existing skeleton/spinner only for first load.
|
||||
* On refresh failure, keep visible stale data and show a small non-blocking error only where useful.
|
||||
|
||||
## Route prefetch strategy
|
||||
|
||||
In `AppShell`, prefetch cheap likely-next data:
|
||||
|
||||
* After auth/profile load:
|
||||
* points balance
|
||||
* unread notification count
|
||||
* dashboard history summary
|
||||
* On sidebar hover/focus/click intent:
|
||||
* Store: packages + points
|
||||
* History: history list
|
||||
* Settings/Profile/General: profile
|
||||
* Divination manual/auto: points
|
||||
* Notifications: notification list + unread count
|
||||
|
||||
Prefetch must be bounded:
|
||||
|
||||
* only authenticated routes
|
||||
* no SSE prefetch
|
||||
* no mutation prefetch
|
||||
* no repeated prefetch while in-flight or fresh
|
||||
|
||||
## Mutation and invalidation policy
|
||||
|
||||
### Profile/settings
|
||||
|
||||
* `updateUserProfile` and `uploadAvatar`
|
||||
* set `['profile']` to returned profile
|
||||
* update `AppShell` user context immediately
|
||||
* `updateUserSettings`
|
||||
* set `['profile']` to returned profile
|
||||
* redirect language only after cache/context update
|
||||
|
||||
### Points/store
|
||||
|
||||
* Purchase flow or future payment success:
|
||||
* invalidate `['points', 'balance']`
|
||||
* Divination run:
|
||||
* optimistically mark points stale when run is accepted
|
||||
* invalidate points and history after run finishes
|
||||
|
||||
### History
|
||||
|
||||
* Divination run success:
|
||||
* set result in sessionStorage/router state as today
|
||||
* invalidate history summary/list
|
||||
* optionally seed `['history', 'thread', threadId]` if enough data exists
|
||||
* Follow-up success:
|
||||
* invalidate `['history', 'thread', threadId]`
|
||||
* invalidate summary/list
|
||||
|
||||
### Notifications
|
||||
|
||||
* Mark one read:
|
||||
* patch `['notifications', locale]`
|
||||
* decrement `['notifications', 'unread-count']` locally
|
||||
* Mark all read:
|
||||
* patch list to all read
|
||||
* set unread count to 0
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: measurement and guardrails
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Add a dev-only request logger around `authFetch`/`apiRequest`.
|
||||
* Capture before counts for:
|
||||
* login -> dashboard
|
||||
* dashboard -> settings -> profile -> general
|
||||
* dashboard -> manual/auto divination
|
||||
* dashboard -> history
|
||||
* dashboard -> store
|
||||
* Keep UI unchanged.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* We can prove before/after request count improvements.
|
||||
|
||||
### Phase 2: data client foundation
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Add typed cache/data client.
|
||||
* Move existing points TTL cache into the data client.
|
||||
* Add clear-on-logout/auth-failure hook.
|
||||
* Add unit-light self checks where possible, or targeted browser checks if test infra is absent.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* In-flight duplicate calls collapse into one request.
|
||||
* Cache invalidation is explicit and inspectable.
|
||||
|
||||
### Phase 3: profile/settings context refactor
|
||||
|
||||
Deliverables:
|
||||
|
||||
* AppShell uses profile resource.
|
||||
* SettingsPage/ProfileDetailPage/GeneralSettingsPage use profile resource/context instead of unconditional fetch.
|
||||
* Mutations update profile cache and shell state.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* Dashboard -> settings -> profile -> general should not issue repeated `GET /users/me/profile` while profile is fresh.
|
||||
|
||||
### Phase 4: points, packages, dashboard
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Points uses data client TTL + in-flight dedupe.
|
||||
* Packages cached with long TTL.
|
||||
* Dashboard data uses shared resources.
|
||||
* Store/divination/settings reuse points cache.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* Dashboard -> store -> divination does not repeatedly fetch points/packages while fresh.
|
||||
|
||||
### Phase 5: history and notifications
|
||||
|
||||
Deliverables:
|
||||
|
||||
* History list/summary/thread resources.
|
||||
* Dashboard and HistoryListPage share cached history.
|
||||
* Notification list/unread resources with local patch after mark-read.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* Dashboard -> history reuses fresh history data.
|
||||
* Mark-read updates unread count without waiting for dashboard reload.
|
||||
|
||||
### Phase 6: perceived-performance polish
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Replace full-page loading on cached routes with stale data + subtle refreshing state.
|
||||
* Add bounded route prefetch in AppShell.
|
||||
* Add regression browser checks for mobile/desktop.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* UI remains visually unchanged at rest.
|
||||
* Route transitions feel instant when data is cached.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Stale user-visible data | Short TTL for volatile data; explicit invalidation after writes. |
|
||||
| Auth cache leaking between users | Clear all data cache on logout, missing auth, refresh failure, and user id change. |
|
||||
| Hidden background refresh errors | Keep stale data visible but expose reload/error state for first-load failures. |
|
||||
| Over-prefetch increasing traffic | Prefetch only small GET endpoints and rely on in-flight/fresh checks. |
|
||||
| Refactor touching too many pages at once | Ship by phases with request-count verification after each phase. |
|
||||
| UI drift | Do not alter markup/layout except replacing data source/loading conditions. |
|
||||
|
||||
## Recommended final architecture
|
||||
|
||||
```
|
||||
React Page
|
||||
-> useProfile/usePoints/useHistory/useNotifications
|
||||
-> Resource wrapper (keys, TTL, invalidation)
|
||||
-> DataClient (cache, in-flight dedupe, stale-while-revalidate)
|
||||
-> typed API functions in api.ts
|
||||
-> authFetch/apiRequest transport
|
||||
-> backend
|
||||
```
|
||||
|
||||
The core principle: components describe what data they need; resource wrappers decide when the backend actually needs to be called.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Web request audit
|
||||
|
||||
## Initial request topology
|
||||
|
||||
### App shell and auth
|
||||
|
||||
| Area | Request(s) | Current behavior | Waste / risk |
|
||||
| --- | --- | --- | --- |
|
||||
| `AppShell` | `refreshAccessToken()`, `getUserProfile()` | Runs on authenticated shell mount. Profile is stored in `UserSettingsContext`. | This should be the primary profile/settings source for child pages. |
|
||||
| `LoginForm` existing auth check | `refreshAccessToken()`, `getUserProfile()` | Valid existing session fetches profile to choose locale before redirect. | Acceptable, but could reuse stored auth language if persisted in future. |
|
||||
| `LoginForm` submit | `loginWithEmail()`, `getUserProfile()` | Login response lacks settings, so profile is fetched to choose locale. | Acceptable until backend returns language/settings in auth response. |
|
||||
|
||||
### Duplicate profile/settings reads
|
||||
|
||||
| Page | Current request | Better behavior |
|
||||
| --- | --- | --- |
|
||||
| `SettingsPage` | `getUserProfile()` + `getPointsBalance()` | Reuse `UserSettingsContext.userProfile`; fetch only points. |
|
||||
| `GeneralSettingsPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserSettings()`. |
|
||||
| `ProfileDetailPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserProfile()` / avatar upload. |
|
||||
| `ManualDivinationPage` / `AutoDivinationPage` | import `getUserProfile` but rely on context for profile; `getUserProfile` import appears unused | Remove unused import and keep using context. |
|
||||
|
||||
### Points and store reads
|
||||
|
||||
| Page | Current request | Better behavior |
|
||||
| --- | --- | --- |
|
||||
| `Dashboard` | `getPointsBalance()`, `getUnreadNotificationCount()`, `getAgentHistory()` | Points has TTL cache, but should dedupe in-flight. History can be shared with history list via short TTL cache. |
|
||||
| `SettingsPage` | `getPointsBalance()` | Keep cache, add in-flight dedupe and stale-while-revalidate option. |
|
||||
| `StorePage` | `getPointsBalance()`, `getPackages()` | Cache packages for a longer TTL because packages are stable configuration. Keep explicit invalidation after purchase/payment flows. |
|
||||
| `ManualDivinationPage` / `AutoDivinationPage` | `getPointsBalance()` | Reuse points cache/in-flight dedupe; consider prefetch when entering divination nav group. |
|
||||
|
||||
### History and notifications
|
||||
|
||||
| Area | Current request | Better behavior |
|
||||
| --- | --- | --- |
|
||||
| `Dashboard` | `getAgentHistory()` for latest four | Add short TTL/in-flight cache for history summary; history list can reuse if still fresh. |
|
||||
| `HistoryListPage` | `getAgentHistory()` full list | Reuse cache populated by dashboard, then refresh in background. |
|
||||
| `NotificationPage` | `getNotifications(locale)` | Cache list briefly per locale. Invalidate/update locally after mark-read actions. |
|
||||
| Dashboard unread badge | `getUnreadNotificationCount()` | Cache briefly; invalidate/update after mark-read or mark-all-read. |
|
||||
|
||||
## Priority plan
|
||||
|
||||
1. Add a small typed cache helper in `web/src/lib/api.ts` or adjacent `web/src/lib/api-cache.ts` for:
|
||||
- TTL cache
|
||||
- in-flight promise dedupe
|
||||
- explicit invalidation
|
||||
- optional stale return with background refresh where UI can support it
|
||||
2. Apply first to profile/settings, points, packages, history summary/list, notification list/count.
|
||||
3. Refactor pages to use `UserSettingsContext` for profile reads.
|
||||
4. Add prefetch hooks at AppShell/nav boundaries where it is cheap and safe.
|
||||
5. Browser-verify request count reductions on:
|
||||
- login -> dashboard
|
||||
- dashboard -> settings -> profile -> general
|
||||
- dashboard -> manual/auto divination
|
||||
- dashboard -> history
|
||||
- dashboard -> store
|
||||
|
||||
## Open implementation questions
|
||||
|
||||
* Whether to implement a minimal local cache helper or introduce a query library. Current repo has no query dependency, so default recommendation is a minimal helper first.
|
||||
* Whether auth login response should eventually include profile language/settings to avoid immediate post-login profile fetch. This would be a backend contract change and is out of scope for the first frontend-only optimization pass.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "audit-and-optimize-web-performance",
|
||||
"name": "audit-and-optimize-web-performance",
|
||||
"title": "Audit and optimize web performance",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "zl-q",
|
||||
"assignee": "zl-q",
|
||||
"createdAt": "2026-05-10",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "dev",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
Reference in New Issue
Block a user