Skip to content

Dashboard Performance Implementation Plan

Migrated from root technical docs.

Status: Active Last updated: 2026-04-29 Owner: Dashboard + Platform teams

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.
  • Replacing TanStack Start, TanStack Router, or TanStack Query.
  • Changing authentication semantics or weakening protected route checks.
  • Manual manualChunks tuning 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.

Build command used for the review:

Terminal window
pnpm --filter @apps/dashboard build

Observed client build pressure points from the review build:

  • router: ~3,058 kB raw / ~881 kB gzip
  • DashboardLayout: ~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 gzip
  • DashboardLayout: ~222 KiB raw / ~68 KiB gzip
  • bug-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.

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 recordPerfMark hooks.
  • Document which checks are CI-only and which are local/manual.

Suggested files:

  • apps/dashboard/package.json
  • apps/dashboard/vite.config.ts
  • apps/dashboard/scripts/
  • docs/plans/dashboard-performance-implementation-plan.md

Acceptance criteria:

  • pnpm --filter @apps/dashboard build still 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:start immediately before hydrateRoot() and app:hydration:complete from a post-hydration effect.
  • Browser monitoring init records app:monitoring:init:start and app: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:debug or ?debug=1, then inspect window.__pubdashDebug.perf() and window.__pubdashDebug.events.

Target: start React hydration as early as possible.

Tasks:

  • Change browser monitoring initialization so hydrateRoot() is not blocked by await 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.log build/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.tsx
  • apps/dashboard/src/shared/telemetry/browser-monitoring.ts
  • packages/utils/src/monitoring-browser.ts
  • apps/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/browser import.
  • 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 DebugWidget with a lazy conditional import that only runs when import.meta.env.DEV or VITE_DEBUG_AUTH=true.
  • Split app event exports so production layout imports event hooks from a hook-only module, not the barrel that also exports EventDebugPanel and EventCatalog.
  • Lazy-load EventDebugPanel only when isEventDebugEnabled() 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/events and move them to type-only platform event modules if they are causing unnecessary server bundle coupling.

Suggested files:

  • apps/dashboard/src/routes/__root.tsx
  • apps/dashboard/src/app/layouts/RootLayout.tsx
  • apps/dashboard/src/app/events/index.ts
  • apps/dashboard/src/app/events/hooks/index.ts
  • apps/dashboard/src/app/events/components/EventDebugPanel.tsx
  • packages/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 AppEvents type from @legaciti/platform-events instead of the app event compatibility barrel.
  • The remaining @/app/events references 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.ts
  • packages/platform-i18n/src/i18n-hooks.ts
  • apps/dashboard/src/shared/i18n/i18n.ts
  • apps/dashboard/src/routes/{-$locale}.tsx
  • packages/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/dashboard no longer statically imports en, pt, people.en, or people.pt JSON 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_NAMESPACES keeps 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 defaultPreloadStaleTime from 0 to 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 beforeLoad and child requireWorkspaceModule checks where possible.
  • Add tests for auth redirects and workspace module denial after any cache behavior change.

Suggested files:

  • apps/dashboard/src/app/router/index.ts
  • apps/dashboard/src/app/routing/dashboard-parent-before-load.ts
  • apps/dashboard/src/app/routing/middleware.ts
  • apps/dashboard/src/features/auth/queries.ts
  • apps/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 /me read for the dashboard parent route, preserving the auth gate semantics for ordinary dashboard entry.
  • Child workspace and superadmin guards can now reuse a /me result only when it was updated within AUTH_NAVIGATION_USER_CACHE_MS (1 second), which removes duplicate parent/child /me reads 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: 100 settings concentrated on heavier index routes; the global defaultPreload: "intent" and defaultPreloadStaleTime handle the rest.
  • Added coverage for recent /me cache 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-tools loader to either await/return ensureQueryData or explicitly use void prefetchQuery with component-level pending UI.
  • Review workspace users and admin pages for the same loader consistency.

Suggested files:

  • apps/dashboard/src/routes/{-$locale}/(dashboard)/activity.tsx
  • apps/dashboard/src/features/activity/pages/ActivityPage.tsx
  • packages/feature-activity/src/queries.ts
  • apps/dashboard/src/routes/{-$locale}/(dashboard)/workspace-settings.tsx
  • apps/dashboard/src/features/workspace-settings/pages/WorkspaceSettingsPage.tsx
  • apps/dashboard/src/routes/{-$locale}/(dashboard)/site-tools.tsx
  • apps/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 useIngestionSync to 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.ts
  • apps/dashboard/src/shared/hooks/useIngestionSync.ts
  • apps/dashboard/src/server/functions/activity/index.ts
  • packages/feature-activity/src/api.ts
  • packages/feature-activity/src/queries.ts

Implementation notes:

  • Publication resync toast polling now calls a narrow getPublicationResyncJobEvents server function keyed by active tracked job IDs instead of fetching a broad activity page and filtering on the client.
  • The endpoint filters activity_events by workspace, recent lookback window, publication resync event types, and either resource_id or metadata_json.job_id so 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_types array and build a single IN predicate, preserving the existing single event_type filter 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.
  • useIngestionSync tracks 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, and types.
  • 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.json
  • packages/feature-people/src/index.ts
  • packages/feature-publications/package.json
  • packages/feature-publications/src/index.ts
  • packages/feature-activity/package.json
  • packages/feature-activity/src/index.ts
  • apps/dashboard/src/features/*/index.ts
  • Dashboard route/page imports under apps/dashboard/src/routes/ and apps/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-people barrels.
  • 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.tsx
  • apps/dashboard/src/features/publications/pages/PublicationsTrashPage.tsx
  • apps/dashboard/src/features/publications/pages/sections/PublicationsIndexTableSection.tsx
  • apps/dashboard/src/features/publications/components/PublicationsTable.tsx
  • apps/dashboard/src/features/publications/components/EditModal.tsx
  • apps/dashboard/src/features/works/components/WorkEditModal.tsx
  • apps/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.

Run after each phase that touches dashboard runtime behavior:

Terminal window
pnpm --filter @apps/dashboard type-check
pnpm --filter @apps/dashboard test
pnpm --filter @apps/dashboard build
pnpm --filter @apps/dashboard analyze:bundle:check

Run before merging larger cross-package phases:

Terminal window
pnpm type-check
pnpm test:vitest
pnpm build

Manual smoke paths:

  • /login
  • /
  • /publications/
  • /people/
  • /works/
  • /projects/
  • /activity
  • /workspace-settings
  • /site-tools as superadmin
  • debug panels in DEV and 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 from window.__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.
  1. Phase 0: measurement and guardrails.
  2. Phase 1: monitoring hydration blocker.
  3. Phase 2: debug/event lazy splitting.
  4. Phase 4: router preload stale time and auth dedupe investigation.
  5. Phase 5: loader gaps for activity/settings/site-tools.
  6. Phase 6: polling and invalidation pressure.
  7. Phase 3: dynamic i18n resources.
  8. Phase 7: barrel/subpath export cleanup.
  9. 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.

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