18 KiB
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:
[NoAdapterInstalled] Cannot use server-rendered pages without an adapter.
The blocker is directly explained by six dynamic Astro route files that export prerender = false:
// web/src/pages/en/history/[id].astro:2
export const prerender = false;
The same pattern exists in:
web/src/pages/en/history/[id].astro:2web/src/pages/zh/history/[id].astro:2web/src/pages/zh_Hant/history/[id].astro:2web/src/pages/en/history/[id]/followup.astro:2web/src/pages/zh/history/[id]/followup.astro:2web/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:
// 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:
- If deployment target is static hosting, remove
export const prerender = falseand add a static fallback strategy for client-owned history routes, e.g. Astro static dynamic route support withgetStaticPaths()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. - If deployment target requires on-demand rendering, add the correct Astro server adapter in
web/astro.config.mjsand the matching package inweb/package.json. - 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:
// 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:
// 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:
// 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:
- Collapse authenticated-route auth boot into one owner. Prefer
AppShellbecause it already owns profile, locale redirect, nav prefetch, and user context. - Remove
AuthProviderfromAppLayoutif no routed child consumesuseAuth(), or turn it into a passive context seeded fromgetAuth()without forcing refresh. - In the remaining boot path, call
refreshAccessToken()only whenisTokenExpired()is true; otherwise seedauthUserfromgetAuth().user. - Measure initial
/zh/dashboardload 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:
// 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`;
// 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:
- Short term: after login, redirect to
/${backendLanguageToLocale(language)}/dashboardwithout the extra profile read when the user selected/submitted the language and backend stores it. - If backend user preference must remain authoritative, persist only the minimal post-login locale/profile seed into
sessionStorageand hydrateprofileKeybeforeAppShellcallsgetProfileResource(). - Long term: have the auth/session response include profile language/settings, which eliminates the post-login profile GET entirely.
- Target measurable improvement: login success path should drop from
login + profile + dashboard profiletologin + dashboard profile, or tologinif profile seed is safe.
Priority 4: invalidation can actively create duplicate refetches
The resource layer intentionally uses one key for history summary and list:
// web/src/lib/resources.ts:49-50
export const historyListKey = ['history', 'list'] as const;
export const historySummaryKey = historyListKey;
But invalidation calls both keys:
// 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:
// web/src/components/DivinationProcessingOverlay.tsx:157-159
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
invalidatePoints();
// web/src/components/DivinationProcessingOverlay.tsx:257-259
setStep('done');
invalidatePoints();
invalidateHistory(threadId);
Follow-up does the same around SSE completion:
// 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:
- Make
invalidateHistory()dedupe equal prefixes before invalidating, or stop aliasinghistorySummaryKeyandhistoryListKey. - Add an option to mark entries stale without deleting active in-flight promises, e.g.
invalidate(prefix, { refetchActive: true, preserveInFlight: true }). - 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.
- Add instrumentation in
data-client.tsaroundstartFetch()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:
// 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:
// 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:
- Convert route components in
DashboardApp.tsxtoReact.lazy(() => import('./RoutePage'))and wrap<Routes>in a small Suspense fallback that preserves shell layout. - 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. - Split heavy local copy objects in
ManualDivinationPage.tsx,AutoDivinationPage.tsx, andDivinationProcessingOverlay.tsxwith the route component rather than the dashboard shell. - 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:
// web/src/components/AppShell.tsx:156
<img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" />
// web/src/components/ManualDivinationPage.tsx:61-63
<img
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
// 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:
- Decide whether
web/public/images/**should be source-controlled. If yes, copy optimized web assets there rather than relying on staledist/. - Resize/compress qigua images to their displayed dimensions and convert to WebP/AVIF with JPG fallback only if needed.
- Replace
logo.pngwith optimized SVG or small WebP/PNG; 142 KB is too high for a small nav/login logo. - Do not ship tutorial PNGs unless a web tutorial page references them. If they are needed later, lazy-load and optimize them.
- 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:
// 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" />
// 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:
- Replace remaining
material-symbols-roundedspans withIcon.tsxwhere icons already exist. - If the font remains necessary, self-host a subset or load it only in authenticated route chunks that use it.
- Add
preconnectonly 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:
// 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:
// 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:
- Add
.env.exampledocumentingPUBLIC_API_URLfor production and local verification. - Make dev proxy target configurable, e.g.
DEV_API_PROXY_TARGET ?? 'https://api.meeyao.com'. - 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 — Astro routes prerender by default in static mode; exporting
prerender = falseopts a page into on-demand server rendering. - Astro NoAdapterInstalled error reference — 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 commit1e4871eimplemented.
Caveats / Not Found
pnpm run buildcurrently fails withNoAdapterInstalled, so current production bundle sizes could not be regenerated.web/dist/**exists but is stale relative to the current failing build. Sizes fromweb/distare useful directional evidence only.pnpm exec astro syncpassed.pnpm exec tsc --noEmitcould not run becausetypescript/tscis not installed inweb/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.