# 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; --- ``` 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` 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 ``` `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 `` 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 MeiYao ``` ```tsx // web/src/components/ManualDivinationPage.tsx:61-63 ``` ```astro // web/src/layouts/Marketing.astro:20 ``` 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.