chore(task): archive 05-10-audit-and-optimize-web-performance

This commit is contained in:
zl-q
2026-05-11 19:46:35 +08:00
parent 14f99d6dc4
commit 3cc5999383
8 changed files with 142 additions and 2 deletions
@@ -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,127 @@
# 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.
## Implementation Notes - 2026-05-10 Second Pass
* Fixed the Astro build blocker without breaking existing direct-refresh history URLs by adding the `@astrojs/node` adapter and switching the web build to server output. The six `prerender = false` dynamic history shell pages remain in place for `/{locale}/history/:id` and `/{locale}/history/:id/followup`.
* New in-app links still use the static shell URLs with `threadId` query parameters: `/{locale}/history/result?threadId=...` and `/{locale}/history/followup?threadId=...`, while the legacy direct URLs continue to resolve through Astro and React Router.
* Split authenticated page bodies in `DashboardApp.tsx` with `React.lazy` and `Suspense`, keeping `AppShell`, nav config, auth guard, and link interception eager. The initial `DashboardApp` chunk dropped from the prior ~142 KB shape to ~54 KB in the built output, with heavy pages emitted as separate chunks.
* Optimized static image assets in place under `web/design/assets/images` while preserving filenames and references. Largest qigua result images are now about 125-130 KB each, coin images about 26-27 KB, logo about 42 KB, and tutorial images reduced from roughly 800 KB / 2.5 MB / 3.0 MB to about 372 KB / 1.0 MB / 1.2 MB.
* Added `primeHistoryThreadFromSnapshot()` so dashboard/history-list clicks seed the thread resource from an already-loaded history snapshot before navigating to the result page. This avoids an immediate duplicate thread GET when list data already contains the target thread.
* Added real web typecheck dependencies (`@astrojs/check`, `typescript`, `@types/node`) and fixed the surfaced type gaps: `DashboardApp` now uses typed i18n sections, settings route shells reuse `DashboardAppPage.astro`, and the about-page ICP fields are present in all locales.
## Verification - 2026-05-10 Second Pass
* `npm run build` in `web/`: passed. Astro built server output with `@astrojs/node`, preserving on-demand dynamic history shells.
* `npm exec astro sync` in `web/`: passed.
* `git diff --check`: passed.
* `npm exec tsc -- --noEmit`: passed.
* `npm exec astro check`: passed with 0 errors; remaining diagnostics are hints for pre-existing cleanup candidates.
* Server preview smoke via `astro preview` on `127.0.0.1:4322`: `GET /zh/dashboard`, `/zh/history/smoke-thread`, `/zh/history/smoke-thread/followup`, `/zh/history/result?threadId=smoke-thread`, `/zh/history/followup?threadId=smoke-thread`, `/en/history/smoke-thread`, and `/zh_Hant/history/smoke-thread/followup` all returned `200 text/html`.
* Built client chunks: `DashboardApp` is ~54 KB, with lazy page chunks emitted separately (`ManualDivinationPage` ~19 KB, `AutoDivinationPage` ~19 KB, `DivinationResultPage` ~14 KB, `HistoryFollowUpPage` ~9 KB, `HistoryListPage` ~7 KB). Built asset folders: `dist/client/_astro` ~520 KB and `dist/client/images` ~4.2 MB.
## Implementation Notes - 2026-05-10 Third Pass
* Removed the authenticated layout-level `AuthProvider` wrapper and deleted the unused provider/context file. `AppShell` remains the only authenticated session recovery and route-guard owner for dashboard routes.
* Login and existing-session checks now use the shared profile resource instead of calling `getUserProfile()` directly, so login-to-dashboard can reuse the profile cache that was already fetched for language redirect decisions.
* Successful email login clears the shared data cache before storing the new session to avoid carrying authenticated resource data across account changes.
## Verification - 2026-05-10 Third Pass
* `npm exec astro sync` in `web/`: passed.
* `npm exec tsc -- --noEmit` in `web/`: passed.
* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints.
* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning.
* `git diff --check`: passed.
## Implementation Notes - 2026-05-10 Fourth Pass
* Fresh email-code login submit now redirects using the locale implied by the submitted backend language (`localeToBackendLanguage(locale)` -> `backendLanguageToLocale(language)`) instead of fetching profile before navigation. This removes the avoidable profile GET whose in-memory cache was discarded by the full-page dashboard load.
* Existing-auth login-page recovery still fetches profile after token refresh because the stored profile language preference is the authoritative source for redirecting an already-authenticated user from the login page. The current resource layer is intentionally in-memory only, so it cannot provide a reload-safe handoff for `window.location.href`; optimizing the fresh-login side avoids the waste while preserving stored-preference redirects for recovered sessions.
## Verification - 2026-05-10 Fourth Pass
* `npm exec tsc -- --noEmit` in `web/`: passed.
* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints.
* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning.
* `git diff --check`: passed.
@@ -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,376 @@
# Research: Remaining web performance bottlenecks after 1e4871e
- **Query**: Deeply audit the web frontend for remaining performance bottlenecks after commit 1e4871e. Focus on repeated authenticated requests still possible, first-load route waterfall, oversized static assets, bundle/code-splitting opportunities, build-layer blocker (`NoAdapterInstalled`), dev/prod config, and measurable request/cache improvements.
- **Scope**: mixed
- **Date**: 2026-05-10
## Findings
### Files Found
| File Path | Description |
|---|---|
| `web/src/lib/data-client.ts:77` | In-memory query cache with in-flight dedupe, TTL, stale-while-revalidate, invalidation, subscriptions. |
| `web/src/lib/resources.ts:46` | Resource keys, TTLs, hooks, prefetch policies, and mutation invalidation wrappers. |
| `web/src/components/AppShell.tsx:74` | Authenticated shell boot sequence: refresh token, load profile resource, then prefetch dashboard basics. |
| `web/src/components/AuthProvider.tsx:37` | Separate auth recovery wrapper used by `AppLayout`, causing another refresh lifecycle before/alongside `AppShell`. |
| `web/src/components/DashboardApp.tsx:1` | Eagerly imports every authenticated route component into one React island. |
| `web/src/components/DashboardAppPage.astro:26` | Mounts the full dashboard app with `client:only="react"` for every authenticated route page. |
| `web/src/pages/*/history/[id].astro:2` | Dynamic history result routes opt out of prerendering with `export const prerender = false`. |
| `web/src/pages/*/history/[id]/followup.astro:2` | Dynamic follow-up routes opt out of prerendering with `export const prerender = false`. |
| `web/astro.config.mjs:6` | No Astro adapter configured; Vite dev proxy points `/api` at production API. |
| `web/src/layouts/App.astro:19` | Loads full Material Symbols variable font CSS from Google on authenticated app pages. |
| `web/src/layouts/Marketing.astro:20` | Loads same full Material Symbols font CSS on marketing pages. |
| `web/design/assets/images/**` | Source static images include multiple 0.6 MB to 3.0 MB PNG/JPG files. |
| `web/public/` | Only contains favicon files; current code references `/images/logo.png` and `/images/qigua/*.jpg`. |
| `web/dist/**` | Existing build output from an earlier run contains copied large image assets and one large dashboard JS chunk; current build cannot regenerate it. |
### Code Patterns
#### Priority 1: build remains blocked, which prevents reliable production measurement
`pnpm run build` fails:
```text
[NoAdapterInstalled] Cannot use server-rendered pages without an adapter.
```
The blocker is directly explained by six dynamic Astro route files that export `prerender = false`:
```astro
// web/src/pages/en/history/[id].astro:2
export const prerender = false;
```
The same pattern exists in:
- `web/src/pages/en/history/[id].astro:2`
- `web/src/pages/zh/history/[id].astro:2`
- `web/src/pages/zh_Hant/history/[id].astro:2`
- `web/src/pages/en/history/[id]/followup.astro:2`
- `web/src/pages/zh/history/[id]/followup.astro:2`
- `web/src/pages/zh_Hant/history/[id]/followup.astro:2`
These routes do not use server-only data. They only read `Astro.params.id` and render the same client-side `DashboardAppPage`:
```astro
// web/src/pages/en/history/[id].astro:4-10
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
```
Implementation steps:
1. If deployment target is static hosting, remove `export const prerender = false` and add a static fallback strategy for client-owned history routes, e.g. Astro static dynamic route support with `getStaticPaths()` only if finite paths exist, or replace file-based `[id]` pages with a static catch-all/client app entry supported by the host rewrite rules.
2. If deployment target requires on-demand rendering, add the correct Astro server adapter in `web/astro.config.mjs` and the matching package in `web/package.json`.
3. Make build success the gate before bundle-size, image, and request-count acceptance data are considered final.
#### Priority 2: first authenticated load can still spend requests on duplicate auth/session work
`AppLayout` wraps every authenticated route with `AuthProvider client:load`:
```astro
// web/src/layouts/App.astro:23-25
<AuthProvider client:load>
<slot />
</AuthProvider>
```
`AuthProvider` refreshes on mount regardless of whether the access token is still fresh:
```tsx
// web/src/components/AuthProvider.tsx:37-45
const auth = getAuth();
if (!auth?.refresh_token) {
clearAuth();
redirectToLogin();
return;
}
refreshAccessToken()
```
`AppShell` then has its own auth boot sequence and also calls `refreshAccessToken()` before loading profile:
```tsx
// web/src/components/AppShell.tsx:82-86
refreshAccessToken()
.then((data) => {
if (alive) setAuthUser(data.user);
return getProfileResource();
})
```
The single-flight refresh promise in `web/src/lib/auth.ts:172` dedupes concurrent refreshes, but this is still a redundant lifecycle. Depending on hydration timing, it can be one shared refresh or two sequential refresh calls. It also delays the authenticated shell behind two components that both show full-screen loading spinners.
Implementation steps:
1. Collapse authenticated-route auth boot into one owner. Prefer `AppShell` because it already owns profile, locale redirect, nav prefetch, and user context.
2. Remove `AuthProvider` from `AppLayout` if no routed child consumes `useAuth()`, or turn it into a passive context seeded from `getAuth()` without forcing refresh.
3. In the remaining boot path, call `refreshAccessToken()` only when `isTokenExpired()` is true; otherwise seed `authUser` from `getAuth().user`.
4. Measure initial `/zh/dashboard` load request count before and after. Target: avoid a refresh request when the token is fresh, and keep one refresh maximum when expired.
#### Priority 3: login to dashboard still repeats profile/session requests
`LoginForm` checks existing sessions and submit success by calling `getUserProfile()` directly:
```tsx
// web/src/components/LoginForm.tsx:59-63
await refreshAccessToken();
const profile = await getUserProfile();
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
window.location.href = `/${userLocale}/dashboard`;
```
```tsx
// web/src/components/LoginForm.tsx:100-104
await loginWithEmail(email, code, language, timezone);
const profile = await getUserProfile();
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language);
window.location.href = `/${userLocale}/dashboard`;
```
Because the redirect reloads the app, the in-memory profile resource is empty on the dashboard. `AppShell` then fetches profile again through `getProfileResource()` at `web/src/components/AppShell.tsx:85`.
Implementation steps:
1. Short term: after login, redirect to `/${backendLanguageToLocale(language)}/dashboard` without the extra profile read when the user selected/submitted the language and backend stores it.
2. If backend user preference must remain authoritative, persist only the minimal post-login locale/profile seed into `sessionStorage` and hydrate `profileKey` before `AppShell` calls `getProfileResource()`.
3. Long term: have the auth/session response include profile language/settings, which eliminates the post-login profile GET entirely.
4. Target measurable improvement: login success path should drop from `login + profile + dashboard profile` to `login + dashboard profile`, or to `login` if profile seed is safe.
#### Priority 4: invalidation can actively create duplicate refetches
The resource layer intentionally uses one key for history summary and list:
```ts
// web/src/lib/resources.ts:49-50
export const historyListKey = ['history', 'list'] as const;
export const historySummaryKey = historyListKey;
```
But invalidation calls both keys:
```ts
// web/src/lib/resources.ts:290-293
export function invalidateHistory(threadId?: string): void {
invalidate(historySummaryKey);
invalidate(historyListKey);
if (threadId) invalidate(historyThreadKey(threadId));
}
```
Since the two keys are the same object/value, the first `invalidate()` deletes the entry and notifies subscribers, which can immediately start a fetch and store an in-flight promise. The second `invalidate()` can delete that new in-flight entry and notify again, allowing a second network request for the same history list.
Points have a similar repeated invalidation pattern during run flows:
```tsx
// web/src/components/DivinationProcessingOverlay.tsx:157-159
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
invalidatePoints();
```
```tsx
// web/src/components/DivinationProcessingOverlay.tsx:257-259
setStep('done');
invalidatePoints();
invalidateHistory(threadId);
```
Follow-up does the same around SSE completion:
```tsx
// web/src/components/HistoryFollowUpPage.tsx:166-190
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
invalidatePoints();
...
invalidateHistory(threadId);
invalidatePoints();
const snapshot = await getHistoryThreadResource(threadId, true);
```
Implementation steps:
1. Make `invalidateHistory()` dedupe equal prefixes before invalidating, or stop aliasing `historySummaryKey` and `historyListKey`.
2. Add an option to mark entries stale without deleting active in-flight promises, e.g. `invalidate(prefix, { refetchActive: true, preserveInFlight: true })`.
3. In divination/follow-up flows, prefer one points invalidation at the moment the backend has committed the charge, or use a stale mark after enqueue and one forced reload at finish.
4. Add instrumentation in `data-client.ts` around `startFetch()` in dev builds to count key-level fetch starts. Target: one `['points','balance']` refresh and one history refresh per completed run.
#### Priority 5: first-load route waterfall and bundle shape are still dominated by one eager app island
Every authenticated route page renders the same `DashboardAppPage`, and that mounts one client-only React app:
```astro
// web/src/components/DashboardAppPage.astro:26-27
<AppLayout locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</AppLayout>
```
`DashboardApp.tsx` eagerly imports all route screens:
```tsx
// web/src/components/DashboardApp.tsx:3-15
import AppShell from './AppShell';
import Dashboard from './Dashboard';
import StorePage from './StorePage';
import HistoryListPage from './HistoryListPage';
import DivinationResultPage from './DivinationResultPage';
import HistoryFollowUpPage from './HistoryFollowUpPage';
import NotificationPage from './NotificationPage';
import ProfileDetailPage from './ProfileDetailPage';
import SettingsPage from './SettingsPage';
import GeneralSettingsPage from './GeneralSettingsPage';
import FeedbackPage from './FeedbackPage';
import ManualDivinationPage from './ManualDivinationPage';
import AutoDivinationPage from './AutoDivinationPage';
```
This means `/dashboard` pays parse/compile/download cost for store, notifications, profile edit, manual casting, auto casting, result rendering, follow-up chat, feedback, and settings code before any route transition.
Existing stale `web/dist` output from 2026-05-10 01:07 showed:
| Asset | Raw | Gzip |
|---|---:|---:|
| `web/dist/_astro/client.Bncfyed9.js` | 185,936 bytes | 58,384 bytes |
| `web/dist/_astro/DashboardApp.Df7kJHO-.js` | 142,394 bytes | 39,455 bytes |
| `web/dist/_astro/animations.BXuKIGyB.css` | ~89 KiB | 16,947 bytes |
These numbers are not current-build authoritative because `pnpm run build` now fails, but they confirm the dashboard app chunk is large enough to justify code splitting once the build blocker is fixed.
Implementation steps:
1. Convert route components in `DashboardApp.tsx` to `React.lazy(() => import('./RoutePage'))` and wrap `<Routes>` in a small Suspense fallback that preserves shell layout.
2. Keep `AppShell`, `Dashboard`, and core nav in the initial chunk; lazy-load lower-probability paths such as store, result, follow-up, profile edit, feedback, notifications, manual/auto casting.
3. Split heavy local copy objects in `ManualDivinationPage.tsx`, `AutoDivinationPage.tsx`, and `DivinationProcessingOverlay.tsx` with the route component rather than the dashboard shell.
4. Add bundle budget reporting after the build blocker is fixed.
#### Priority 6: static image assets are missing from `public/` but oversized in source/stale dist
Current source references these public URLs:
```tsx
// web/src/components/AppShell.tsx:156
<img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" />
```
```tsx
// web/src/components/ManualDivinationPage.tsx:61-63
<img
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
```
```tsx
// web/src/components/DivinationResultPage.tsx:24-27
if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg';
if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg';
if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg';
return '/images/qigua/zhongxia.jpg';
```
But `web/public/` currently contains only:
- `web/public/favicon.ico` (655 B)
- `web/public/favicon.svg` (749 B)
The same assets exist under `web/design/assets/images/**` and stale `web/dist/images/**`. Largest source assets:
| File | Size |
|---|---:|
| `web/design/assets/images/tutorial/tutorial_3.png` | 3.0 MB |
| `web/design/assets/images/tutorial/tutorial_2.png` | 2.5 MB |
| `web/design/assets/images/qigua/xiaxia.jpg` | 1.4 MB |
| `web/design/assets/images/tutorial/tutorial_1.png` | 799 KB |
| `web/design/assets/images/qigua/shangshang.jpg` | 768 KB |
| `web/design/assets/images/qigua/zhongxia.jpg` | 601 KB |
| `web/design/assets/images/qigua/zhongshang.jpg` | 596 KB |
| `web/design/assets/images/logo.png` | 142 KB |
Implementation steps:
1. Decide whether `web/public/images/**` should be source-controlled. If yes, copy optimized web assets there rather than relying on stale `dist/`.
2. Resize/compress qigua images to their displayed dimensions and convert to WebP/AVIF with JPG fallback only if needed.
3. Replace `logo.png` with optimized SVG or small WebP/PNG; 142 KB is too high for a small nav/login logo.
4. Do not ship tutorial PNGs unless a web tutorial page references them. If they are needed later, lazy-load and optimize them.
5. Add an asset budget check after build works, e.g. fail if any single public image exceeds 200 KB without explicit rationale.
#### Priority 7: Material Symbols font load is broad and render-blocking
Both app and marketing layouts include the full Material Symbols variable CSS:
```astro
// web/src/layouts/App.astro:19
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
```
```astro
// web/src/layouts/Marketing.astro:20
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
```
The app also has a local `Icon.tsx` component for many icons, so the font is mostly used by settings/store/feedback subpages. The font CSS is loaded before route-specific need and from a third-party origin.
Implementation steps:
1. Replace remaining `material-symbols-rounded` spans with `Icon.tsx` where icons already exist.
2. If the font remains necessary, self-host a subset or load it only in authenticated route chunks that use it.
3. Add `preconnect` only if the remote font remains.
#### Priority 8: dev/prod API config is not explicit enough for repeatable performance verification
`apiBase()` depends on `PUBLIC_API_URL`:
```ts
// web/src/lib/api-client.ts:1-4
const apiBase = (): string => import.meta.env.PUBLIC_API_URL || '';
export function apiUrl(path: string): string {
return path.startsWith('http') ? path : `${apiBase()}${path}`;
}
```
Dev server proxy is hardcoded to production:
```mjs
// web/astro.config.mjs:17-24
server: {
proxy: {
'/api': {
target: 'https://api.meeyao.com',
changeOrigin: true,
secure: true,
},
},
},
```
This makes local request-count testing hit the production-latency path by default, and production behavior depends on `PUBLIC_API_URL` being set correctly in the hosting environment.
Implementation steps:
1. Add `.env.example` documenting `PUBLIC_API_URL` for production and local verification.
2. Make dev proxy target configurable, e.g. `DEV_API_PROXY_TARGET ?? 'https://api.meeyao.com'`.
3. During perf verification, record whether requests go same-origin proxy or direct API origin, because browser connection reuse and CORS/preflight behavior differ.
### External References
- [Astro routing reference](https://docs.astro.build/ja/reference/routing-reference/) — Astro routes prerender by default in static mode; exporting `prerender = false` opts a page into on-demand server rendering.
- [Astro NoAdapterInstalled error reference](https://docs.astro.build/zh-tw/reference/errors/no-adapter-installed/) — server-rendered pages require an appropriate deployment adapter.
### Related Specs
- `.trellis/spec/web/index.md` — Web-specific guidance referenced by the task PRD.
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md` — Active task goals, acceptance criteria, prior implementation notes.
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md` — Original request topology and duplicate request plan.
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md` — Target data-client/resource architecture that commit `1e4871e` implemented.
## Caveats / Not Found
- `pnpm run build` currently fails with `NoAdapterInstalled`, so current production bundle sizes could not be regenerated.
- `web/dist/**` exists but is stale relative to the current failing build. Sizes from `web/dist` are useful directional evidence only.
- `pnpm exec astro sync` passed.
- `pnpm exec tsc --noEmit` could not run because `typescript`/`tsc` is not installed in `web/node_modules`.
- Browser request-count capture was not run in this research pass. The task PRD already notes a previous automation blocker; request-count verification should happen after the build/route blocker and auth boot duplication are addressed.
@@ -0,0 +1,26 @@
{
"id": "audit-and-optimize-web-performance",
"name": "audit-and-optimize-web-performance",
"title": "Audit and optimize web performance",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "zl-q",
"assignee": "zl-q",
"createdAt": "2026-05-10",
"completedAt": "2026-05-11",
"branch": null,
"base_branch": "dev",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}