Files
eryao/.trellis/tasks/archive/2026-05/05-10-audit-and-optimize-web-performance/research/remaining-web-performance-bottlenecks-after-1e4871e.md
T

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: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:

// 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:

// 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:

  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:

// 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:

  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:

// 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:

  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:

// 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:

  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:

// 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:

  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:

// 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:

  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:

// 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:

  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

  • .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.