Dashboard Performance Implementation Plan
Section titled “Dashboard Performance Implementation Plan”Status: Active Last updated: 2026-04-29 Owner: Dashboard + Platform teams
Purpose
Section titled “Purpose”Improve apps/dashboard startup speed, route transition speed, lazy loading, and perceived UI responsiveness without changing product behavior. This plan follows the performance review of the TanStack Start dashboard and turns the findings into staged implementation work.
- Reduce first hydration blockers on the dashboard client entry.
- Keep root/layout chunks focused on always-needed UI only.
- Make route intent preloading and route loaders more consistently useful.
- Reduce avoidable polling, refetch, and invalidation pressure during common workflows.
- Improve loading perception for admin/settings/activity screens and modal-heavy flows.
- Add bundle/performance visibility so regressions are caught before deployment.
Out of Scope
Section titled “Out of Scope”- Replacing TanStack Start, TanStack Router, or TanStack Query.
- Changing authentication semantics or weakening protected route checks.
- Manual
manualChunkstuning unless TanStack Start guidance changes. - Backend SQL optimization, except where a frontend polling/data-shape improvement needs a narrow endpoint.
- Large visual redesign of dashboard pages.
Current Baseline
Section titled “Current Baseline”Build command used for the review:
pnpm --filter @apps/dashboard buildObserved client build pressure points from the review build:
router: ~3,058 kB raw / ~881 kB gzipDashboardLayout: ~228 kB raw / ~70 kB gzip- lazy
bug-reports: ~207 kB raw / ~50 kB gzip styles.css: ~91 kB raw / ~16 kB gzip
Latest tracked client budget report after the startup/debug split, first-fold loader pass, and full dashboard feature subpath-import cleanup:
router: ~2,888 KiB raw / ~828 KiB gzipDashboardLayout: ~222 KiB raw / ~68 KiB gzipbug-reports: ~202 KiB raw / ~48 KiB gzip- root locale chunk: ~78 KiB raw / ~25 KiB gzip
styles.css: ~89 KiB raw / ~16 KiB gzip
Known strengths to preserve:
- Publications, people, works, and projects index routes already use route loaders for first-page data.
- Publications desktop table already memoizes columns and uses row virtualization.
- Query defaults already avoid window-focus refetch and use bounded retries.
- Bug report UI is route-root gated after hydration for authenticated users.
Phase 0: Measurement And Guardrails
Section titled “Phase 0: Measurement And Guardrails”Target: make improvements measurable before changing behavior.
Tasks:
- Add a dashboard bundle analysis script, preferably using a Vite/Rollup visualizer that emits static HTML or JSON artifacts outside committed build output.
- Add a lightweight bundle budget check for the largest client chunks:
router,DashboardLayout, root locale chunk, and route page chunks. - Capture a repeatable local performance scenario list:
- cold login page load
- authenticated dashboard home load
- publications index load
- publications filter/search transition
- workspace settings load
- activity page load
- Record current values for build chunk sizes.
- Record hydration timing marks and obvious long tasks using existing
recordPerfMarkhooks. - Document which checks are CI-only and which are local/manual.
Suggested files:
apps/dashboard/package.jsonapps/dashboard/vite.config.tsapps/dashboard/scripts/docs/plans/dashboard-performance-implementation-plan.md
Acceptance criteria:
pnpm --filter @apps/dashboard buildstill passes.- There is a documented command that reports bundle size/chunk composition.
- The team has a baseline to compare after each phase.
Implementation notes:
- Client boot now records
app:hydration:startimmediately beforehydrateRoot()andapp:hydration:completefrom a post-hydration effect. - Browser monitoring init records
app:monitoring:init:startandapp:monitoring:init:complete, so debug-mode perf output can verify hydration starts before telemetry finishes loading. - Existing debug tools already capture obvious long tasks and event loop lag; enable
pubdash:debugor?debug=1, then inspectwindow.__pubdashDebug.perf()andwindow.__pubdashDebug.events.
Phase 1: Remove Hydration Blockers
Section titled “Phase 1: Remove Hydration Blockers”Target: start React hydration as early as possible.
Tasks:
- Change browser monitoring initialization so
hydrateRoot()is not blocked byawait initBrowserMonitoring(). - Register global error and rejection listeners before hydration, but queue captured errors until monitoring is ready.
- Initialize Sentry/GlitchTip browser monitoring in a post-hydration effect,
queueMicrotask, or idle callback with a timeout fallback. - Gate startup
console.logbuild/i18n diagnostics behind a debug flag or development-only branch. - Add or update tests for the monitoring wrapper so errors before init are either safely ignored or replayed without throwing.
Suggested files:
apps/dashboard/src/app/entry-client.tsxapps/dashboard/src/shared/telemetry/browser-monitoring.tspackages/utils/src/monitoring-browser.tsapps/dashboard/src/shared/debug/debug.ts
Risks and notes:
- Do not lose early crash visibility silently. If replaying queued errors is too invasive, explicitly document that very early pre-monitoring errors are logged locally but not reported.
- Keep monitoring disabled when no DSN is configured.
Acceptance criteria:
- Hydration begins without awaiting the dynamic
@sentry/browserimport. - Browser monitoring still initializes when DSN env vars are set.
- Existing monitoring tests pass or are updated to cover the new flow.
Phase 2: Make Debug And Event UI Truly Lazy
Section titled “Phase 2: Make Debug And Event UI Truly Lazy”Target: keep development/debug panels out of normal production root and layout chunks.
Tasks:
- Replace the static root import of
DebugWidgetwith a lazy conditional import that only runs whenimport.meta.env.DEVorVITE_DEBUG_AUTH=true. - Split app event exports so production layout imports event hooks from a hook-only module, not the barrel that also exports
EventDebugPanelandEventCatalog. - Lazy-load
EventDebugPanelonly whenisEventDebugEnabled()is true. - Ensure
registerDefaultEvents()runs from an effect or module that does not pull debug components into the root chunk. - Check server-side type-only imports from
@/app/eventsand move them to type-only platform event modules if they are causing unnecessary server bundle coupling.
Suggested files:
apps/dashboard/src/routes/__root.tsxapps/dashboard/src/app/layouts/RootLayout.tsxapps/dashboard/src/app/events/index.tsapps/dashboard/src/app/events/hooks/index.tsapps/dashboard/src/app/events/components/EventDebugPanel.tsxpackages/platform-events/src/index.ts
Acceptance criteria:
- Production root/layout chunks no longer include debug panel component code unless debug flags are enabled.
- Debug widgets still work in development and explicit debug environments.
- Event invalidation, router events, and realtime sync behavior are unchanged.
Implementation notes:
- Server event infrastructure now imports the
AppEventstype from@legaciti/platform-eventsinstead of the app event compatibility barrel. - The remaining
@/app/eventsreferences are dashboard layout runtime imports: the store connector, hook-only event hooks, and the lazy debug panel import. - Admin user cache invalidation now imports directly from
@legaciti/platform-events, matching other feature modules.
Phase 3: Split I18n Resources By Locale And Namespace
Section titled “Phase 3: Split I18n Resources By Locale And Namespace”Target: avoid loading every locale resource before hydration.
Tasks:
- Replace eager JSON locale imports with dynamic namespace loading for the active language.
- Keep fallback behavior for English intact.
- Ensure pseudo-locale support still transforms English resources when enabled.
- Keep
recordPerfMark("i18n:init:complete")and locale-switch timing marks. - Add tests for active-locale loading, fallback loading, pseudo-locale, and locale switching.
Suggested files:
packages/platform-i18n/src/dashboard-i18n.tspackages/platform-i18n/src/i18n-hooks.tsapps/dashboard/src/shared/i18n/i18n.tsapps/dashboard/src/routes/{-$locale}.tsxpackages/platform-i18n/src/*.test.ts
Risks and notes:
- Avoid a flash of untranslated keys. Prefer keeping the active locale ready before rendering locale-dependent layouts, but do not block on unused languages.
- Verify both
/default locale and/pt/...routes.
Implementation notes:
@legaciti/platform-i18n/dashboardno longer statically importsen,pt,people.en, orpeople.ptJSON resources.- Locale resources now load through
importDashboardLocale(language, namespace), which dynamically imports only the requested namespace and falls back to English for unsupported languages. - Pseudo-locale requests dynamically load the English namespace first and then apply the existing pseudo transform, preserving development-only pseudo-locale behavior.
DASHBOARD_I18N_NAMESPACESkeeps i18next namespace registration aligned with the dynamic resource resolver.- Added platform i18n tests for English, Portuguese, namespace-specific resources, unsupported-language fallback, unsupported-namespace fallback, and pseudo-locale transformation. Existing dashboard i18n lazy/pseudo tests still pass.
- Dashboard build now emits locale resources as separate small client chunks (
en,pt,people.en,people.pt) instead of embedding them in the startup i18n module.
Acceptance criteria:
- English-only navigation does not eagerly include Portuguese JSON in the startup path.
- Portuguese routes load Portuguese resources correctly.
- Existing i18n tests pass, with added coverage for dynamic resource loading.
Phase 4: Tune Route Preloading And Auth Data Revalidation
Section titled “Phase 4: Tune Route Preloading And Auth Data Revalidation”Target: make hover/focus preloading pay off and avoid unnecessary route-transition network gates.
Tasks:
- Increase
defaultPreloadStaleTimefrom0to a short useful window, initially 10-30 seconds. - Audit route-level
preload,preloadDelay,loaderDeps, and loaders for consistency. - Decide whether
preloadUserData()can use cached user data plus background revalidation on ordinary dashboard transitions. - Keep strict fresh checks for login/logout, invitation acceptance, workspace switching, and permission-sensitive mutations.
- Avoid duplicate user fetches between the dashboard parent
beforeLoadand childrequireWorkspaceModulechecks where possible. - Add tests for auth redirects and workspace module denial after any cache behavior change.
Suggested files:
apps/dashboard/src/app/router/index.tsapps/dashboard/src/app/routing/dashboard-parent-before-load.tsapps/dashboard/src/app/routing/middleware.tsapps/dashboard/src/features/auth/queries.tsapps/dashboard/src/app/routing/*.test.ts
Risks and notes:
- Auth correctness beats speed. If a short user cache could expose stale permission state, keep the forced fetch for authorization but dedupe parent/child calls per navigation.
Implementation notes:
preloadUserData()remains a strict fresh/meread for the dashboard parent route, preserving the auth gate semantics for ordinary dashboard entry.- Child workspace and superadmin guards can now reuse a
/meresult only when it was updated withinAUTH_NAVIGATION_USER_CACHE_MS(1 second), which removes duplicate parent/child/mereads during the same navigation without accepting normal stale auth data. - Login, signup, invitation, logout, and direct auth-sensitive flows continue to use fresh reads or explicit cache clearing paths rather than the recent-cache shortcut.
- Route preload audit found the existing explicit
preload: true/preloadDelay: 100settings concentrated on heavier index routes; the globaldefaultPreload: "intent"anddefaultPreloadStaleTimehandle the rest. - Added coverage for recent
/mecache reuse and stale-cache fallback in auth/query and workspace-module guard tests; existing dashboard parent auth redirect tests still pass.
Acceptance criteria:
- Intent-preloaded routes can be entered within the configured stale window without repeating all preload work.
- Protected route tests still cover unauthenticated redirects, workspace permission denial, and superadmin behavior.
Phase 5: Fill Route Loader Gaps For First-Fold Screens
Section titled “Phase 5: Fill Route Loader Gaps For First-Fold Screens”Target: reduce client-side waterfalls on pages that still fetch all first-fold data after mount.
Tasks:
- Add loaders or prefetch helpers for activity page first-fold data: current user, activity list with default filters, and queue status.
- Add loaders or prefetch helpers for workspace settings first-fold data: current user, workspace users, people profile base URL, event destinations, and visible identity settings.
- Decide which integrations/settings subsections should stay lazy and fetch only when expanded or scrolled into view.
- Fix
site-toolsloader to eitherawait/returnensureQueryDataor explicitly usevoid prefetchQuerywith component-level pending UI. - Review workspace users and admin pages for the same loader consistency.
Suggested files:
apps/dashboard/src/routes/{-$locale}/(dashboard)/activity.tsxapps/dashboard/src/features/activity/pages/ActivityPage.tsxpackages/feature-activity/src/queries.tsapps/dashboard/src/routes/{-$locale}/(dashboard)/workspace-settings.tsxapps/dashboard/src/features/workspace-settings/pages/WorkspaceSettingsPage.tsxapps/dashboard/src/routes/{-$locale}/(dashboard)/site-tools.tsxapps/dashboard/src/routes/{-$locale}/(dashboard)/workspace-users.tsx
Implementation notes:
- Activity route data now shares default date/filter computation with the page so loader-prefetched activity list data matches the first client render query key.
- Workspace settings prefetches only first-fold shared settings data: workspace users, logo, people profile base URL, and event destinations. API keys, integration installations, credentials, audit history, and origin challenges remain component-triggered secondary data.
- Non-auth first-fold prefetches use settled promises so a logo/activity/status fetch failure does not fail the whole route; component query states continue to own inline loading/error UI.
- Workspace users now has a route loader for first-fold current-user and workspace-user data, so the page can render from warm cache instead of fetching all rows after mount.
- Admin overview now prefetches global stats, admin activity prefetches the default activity/email observability window with shared date helpers, and admin workspaces prefetches module data for the first listed workspace after loading the workspace list.
- Admin email templates and admin users already had first-fold loaders; this pass kept those patterns intact and added focused tests for the new route-data helpers.
Acceptance criteria:
- Activity and workspace settings render meaningful first-fold content from prefetched query data when possible.
- Secondary panels do not block the whole route unless they are needed for first paint.
- Existing route tests and page tests pass.
Phase 6: Reduce Polling And Invalidation Pressure
Section titled “Phase 6: Reduce Polling And Invalidation Pressure”Target: keep the UI responsive while background work is happening.
Tasks:
- Replace publication resync toast polling fan-out with a single query endpoint keyed by tracked job IDs, if feasible.
- If a new endpoint is not feasible immediately, batch activity event queries server-side behind one dashboard server function.
- Deduplicate terminal invalidations so stats/timeseries/activity are only invalidated when visible or likely stale for the user workflow.
- Add
refetchType: "active"where inactive query invalidation does not need immediate network work. - Review
useIngestionSyncto avoid invalidating broad query families repeatedly for unchanged terminal statuses. - Add tests for polling stop conditions and invalidation payloads.
Suggested files:
apps/dashboard/src/features/publications/hooks/usePublicationResyncActivityToasts.tsapps/dashboard/src/shared/hooks/useIngestionSync.tsapps/dashboard/src/server/functions/activity/index.tspackages/feature-activity/src/api.tspackages/feature-activity/src/queries.ts
Implementation notes:
- Publication resync toast polling now calls a narrow
getPublicationResyncJobEventsserver function keyed by active tracked job IDs instead of fetching a broad activity page and filtering on the client. - The endpoint filters
activity_eventsby workspace, recent lookback window, publication resync event types, and eitherresource_idormetadata_json.job_idso older event payload shapes remain compatible. - The toast hook still owns terminal detection, toasts, and active-only invalidations; only the polling data source changed.
- Activity list requests now accept a bounded
event_typesarray and build a singleINpredicate, preserving the existing singleevent_typefilter for normal activity list screens. - Publication resync and ingestion activity toast polling now make one activity request per poll interval instead of one request per event type.
useIngestionSynctracks newly terminal job/status keys before emitting broad cache invalidations, avoiding repeated invalidation bursts for unchanged completed/failed statuses.- Publication resync and ingestion terminal invalidations use
refetchType: "active"so inactive dashboard stats, timeseries, people, publication, and activity queries are marked stale without immediate background refetch.
Risks and notes:
- Be careful with queue/resync UX: users should still get clear terminal success/failure toasts.
- Prefer fewer, more specific invalidations over removing invalidation entirely.
Acceptance criteria:
- A single active resync no longer performs one client request per event type every poll interval and no longer scans unrelated activity events on the client.
- Terminal resync and ingestion states still update lists and toasts correctly.
Phase 7: Route Chunk And Barrel Export Cleanup
Section titled “Phase 7: Route Chunk And Barrel Export Cleanup”Target: make lazy route chunks smaller and easier to reason about.
Tasks:
- Add subpath exports for feature packages:
queries,mutations,api,components, andtypes. - Replace route/page imports from broad barrels with specific subpath imports.
- Keep public package entrypoints compatible during migration, then deprecate broad imports in code comments or lint rules.
- Audit imports from
@legaciti/feature-activity,@legaciti/feature-publications,@legaciti/feature-people, and app-local feature barrels. - Re-run the bundle analyzer to confirm route chunks change in the expected direction.
Suggested files:
packages/feature-people/package.jsonpackages/feature-people/src/index.tspackages/feature-publications/package.jsonpackages/feature-publications/src/index.tspackages/feature-activity/package.jsonpackages/feature-activity/src/index.tsapps/dashboard/src/features/*/index.ts- Dashboard route/page imports under
apps/dashboard/src/routes/andapps/dashboard/src/features/
Implementation notes:
- Added package subpath exports for activity, publications, and people feature packages while preserving the broad
.package entrypoints for compatibility. - Marked broad feature package entrypoints as compatibility barrels and directed dashboard runtime code to subpath imports.
- Added dashboard TypeScript path aliases for the new subpaths.
- Migrated the remaining broad dashboard imports for activity, publications, and people feature packages across admin activity, people modal/orcid flows, unverified works, workspace taxonomy settings, tests, and model helpers.
- Current audit count: 0 dashboard imports still use broad
@legaciti/feature-activity,@legaciti/feature-publications, or@legaciti/feature-peoplebarrels. - Bundle analyzer after this pass: router chunk is ~2,888 KiB raw / ~828 KiB gzip. Budgets still pass.
Acceptance criteria:
- Core routes import only the query/mutation/component modules they need.
- Type-check and build pass.
- Bundle report shows less unrelated feature code in route chunks, or documents why tree-shaking already handled it.
Phase 8: Perceived Speed Polish On Tables And Modals
Section titled “Phase 8: Perceived Speed Polish On Tables And Modals”Target: keep interactions feeling immediate even while data changes.
Tasks:
- Remove or debug-gate unused render counters in publications and trash pages.
- Move viewport-specific publications layout behavior toward CSS/container queries where feasible.
- Memoize expensive derived values that are still rebuilt during filter/pagination changes.
- Consider prefetching publication/work detail query data on edit button hover/focus before opening edit modals.
- Replace plain text modal loading states with stable skeletons matching modal dimensions.
- Ensure pagination and filter changes keep previous data visible where appropriate and show inline pending indicators rather than full-page skeletons.
Suggested files:
apps/dashboard/src/features/publications/pages/PublicationsIndexPage.tsxapps/dashboard/src/features/publications/pages/PublicationsTrashPage.tsxapps/dashboard/src/features/publications/pages/sections/PublicationsIndexTableSection.tsxapps/dashboard/src/features/publications/components/PublicationsTable.tsxapps/dashboard/src/features/publications/components/EditModal.tsxapps/dashboard/src/features/works/components/WorkEditModal.tsxapps/dashboard/src/shared/ui/page-skeleton.tsx
Acceptance criteria:
- Filter/pagination transitions do not collapse the page to a full skeleton when previous data exists.
- Edit modal opening has either prefetched content or a stable skeleton with no layout jump.
- Publications and trash pages no longer keep production render counters alive.
Implementation notes:
- Removed the publications index render counter and the trash render-count debug effect.
- Publication and work edit modals now render stable skeleton layouts while detail queries load instead of plain text loading states.
- Publication edit affordances prefetch detail query data on hover/focus before opening the edit modal.
- Publication index and trash tables keep previous query data visible during background refetches and show a stable inline updating indicator.
- Publication table mobile cards memoize deduped researcher metadata and ingestion flags, and trash table props reuse a stable empty resync set.
- Publication table/card switching now lives inside the table component instead of route pages, reducing resize-state blast radius. A pure CSS/container-query switch was not used because it would require rendering both the virtualized desktop table and mobile cards at once or dropping the mobile card UI.
Verification Matrix
Section titled “Verification Matrix”Run after each phase that touches dashboard runtime behavior:
pnpm --filter @apps/dashboard type-checkpnpm --filter @apps/dashboard testpnpm --filter @apps/dashboard buildpnpm --filter @apps/dashboard analyze:bundle:checkRun before merging larger cross-package phases:
pnpm type-checkpnpm test:vitestpnpm buildManual smoke paths:
/login//publications//people//works//projects//activity/workspace-settings/site-toolsas superadmin- debug panels in
DEVand with explicit debug flags /pt/...locale routes
Performance comparison checklist:
- Hydration starts before monitoring import resolves.
- Root/layout client chunks shrink or remain stable after debug/event splitting.
- Active locale resources load without eager unused locale JSON.
- Route preloading avoids duplicate immediate network work within the stale window.
- Activity/settings first-fold screens show useful content sooner.
- Resync/ingestion polling produces fewer background requests during active jobs.
Verification notes:
app:hydration:start,app:hydration:complete, and monitoring init marks are available fromwindow.__pubdashDebug.perf()in debug mode; browser monitoring still lazy-loads Sentry from the scheduled idle task.- Bundle budgets pass after debug/event splitting, dynamic locale resources, route-loader additions, and the resync job event endpoint.
- Loader tests cover activity/settings/admin/workspace-users first-fold prefetch behavior, and polling tests cover terminal toast/invalidation behavior.
Suggested Execution Order
Section titled “Suggested Execution Order”- Phase 0: measurement and guardrails.
- Phase 1: monitoring hydration blocker.
- Phase 2: debug/event lazy splitting.
- Phase 4: router preload stale time and auth dedupe investigation.
- Phase 5: loader gaps for activity/settings/site-tools.
- Phase 6: polling and invalidation pressure.
- Phase 3: dynamic i18n resources.
- Phase 7: barrel/subpath export cleanup.
- Phase 8: perceived-speed UI polish.
This order front-loads low-risk, high-impact startup fixes, then moves into data-loading behavior and broader import-boundary cleanup.
Open Questions
Section titled “Open Questions”- Should authenticated route transitions always force a fresh
/me, or can freshness be scoped to auth-sensitive transitions and workspace changes? - Do we want a new server function for resync job status by job IDs, or should activity remain the canonical event source for resync terminal state?
- Should workspace settings load all panels immediately, or should integrations/audit/credential sections load only when expanded?
- What bundle budget thresholds should fail CI versus only warn?