Skip to content
Modular Screen Architecture for uncrew-apollo-frontend (ADR)

Modular Screen Architecture for uncrew-apollo-frontend (ADR)

Andi Lamprecht Andi Lamprecht ·· 7 min read· Proposed
FieldValue
StatusProposed
Date2026-04-22
AuthorOleksii Naboichenko
DecidersApollo frontend team
RelatedScreen Service Audit and Modular Architecture Plan

Context

The Apollo frontend serves two product surfaces — uncrew and atomx — selected at runtime via environment.appMode (src/config/environment.ts). An audit plus a code double-check surfaced the real structural problems:

  1. Screen availability ≠ screen loading. Every major screen is eagerly imported (src/pages/index.tsx:5-21); no React.lazy / Suspense exists anywhere in the codebase. Vite only splits react/react-dom/react-router (vite.config.ts:36).
  2. SettingsPanel has no role guard at all (src/pages/SettingsPanel/index.tsx:12) — every other major screen hand-rolls isAdmin || isSupervisor ? <Screen/> : <Navigate/> (e.g. MissionConsole/index.tsx:63, VehicleManager/index.tsx:145). This is the most acute correctness gap.
  3. No access-control primitive exists. Each page re-implements its own role check. There is no useCanAccess, <ScreenGate/>, or central registry.
  4. Auth bootstrap is the cold-start critical path. useUserStore.isInitialFetched blocks render in src/pages/index.tsx:27. Until that resolves, no role-aware routing is possible — lazy loading buys nothing unless registry resolution happens inside the auth-ready gate.
  5. Global Zustand stores carry implicit lifecycle assumptions. useMissionsListStore, useUAVInventoryStore, useMapStateStore, useShiftStore are initialized app-wide and several features rely on page-switch cleanup. Introducing Suspense boundaries naively will break these silently.
  6. Some “shell-global” coupling is overstated. useOperatorNotificationStream is already internally role-gated (src/hooks/useOperatorNotificationStream.ts:122); the ETA worker short-circuits on an empty missions store. UnifiedMap traffic behavior is already prop-driven (trafficConfig / isTrafficLayerEnabled) — the concrete defect is that useTrafficSubscription() is called unconditionally at UnifiedMap.tsx:233. These are small fixes, not framework-sized problems.

Decision

Adopt a registry-driven, lazy-loaded, feature-module architecture — scoped tightly to what the code actually needs. Specifically:

  1. useCanAccess(screenId) + <ScreenGate/> primitive lands first, before any restructuring. All existing inline role checks migrate to it. This is prerequisite to a registry being the single source of truth.
  2. Central screen registry — one ScreenDefinition[] drives routes, sidebar, role/flag/product-mode checks, and the lazy loader for each screen.
  3. allowedScreens resolved after auth bootstrapSet<ScreenId> computed from product mode + roles + permissions + tenant allowlist + flags, inside the auth-ready gate. Router and sidebar are built from it; unknown/disallowed routes redirect.
  4. Per-screen lazy loading with a shared Suspense + error boundaryReact.lazy(() => import('@modules/<product>/<screen>/route')). The error boundary must handle chunk-load failures from stale deploys (retry / hard reload). Parent routes (VehicleManager, MissionConsole, MissionManager, LiveMap, MissionPlanner) stop statically importing children.
  5. Store-lifecycle audit before lazy loading ships. Explicit pass over useMissionsListStore, useUAVInventoryStore, useMapStateStore, useShiftStore to document initialization, teardown, and page-switch cleanup — and move cleanup inside feature modules where it belongs.
  6. Feature modules — code reorganized under src/modules/{shared,uncrew,atomx}/<feature>/, colocating route, local providers, stores, queries, and tests. Today’s src/features/ directory contains only helpers and will be absorbed.
  7. Targeted fixes instead of a capability framework. Do not build a generic Capability abstraction yet:
    • Fix UnifiedMap by making useTrafficSubscription / useAirspaceHealth conditional on their config props.
    • Hoist the isOperator check to the mount site of useOperatorNotificationStream (or leave it where it is — it is already gated).
    • Gate the ETA worker mount on “any mission-screen is in allowedScreens.” Revisit a formal capability system only if ≥3 real capabilities emerge.
  8. Deferred / dropped from earlier drafts:
    • Build-time product entrypoints — dropped. Runtime lazy loading plus tenant allowlist meets the stated goal without doubling CI cost.
    • UnifiedMap plugin decomposition — descoped to the prop-gating fix above. A plugin framework is speculative.
    • Generic CapabilityProviders layer — not built until justified by real reuse.

Registry shape (normative)

type ScreenId =
  | 'main' | 'mission-console' | 'mission-manager'
  | 'my-authorizations' | 'authorization-review' | 'jurisdictions'
  | 'live-map' | 'vehicle-manager' | 'user-profile'
  | 'settings' | 'mission-planner';

interface ScreenDefinition {
  id: ScreenId;
  product: 'shared' | 'uncrew' | 'atomx';
  routes: string[];
  nav?: { title: string; icon: string; order: number };
  requiredRoles?: Role[];
  requiredPermissions?: string[];
  requiredFlags?: string[];
  isEnabled: (ctx: ScreenContext) => boolean;
  loader: () => Promise<{ default: React.ComponentType }>;
}

No Capability field yet — add only when a second consumer justifies it.

Rollout

PhaseDeliverableExit criteria
0useCanAccess / <ScreenGate/> primitive; migrate all inline role checks; add guard to SettingsPanelNo page hand-rolls a role check; SettingsPanel direct URL blocked for non-admins; unit tests on the primitive
0.5Central registry encoding today’s rules; resolution runs inside the isInitialFetched gate; no behavior change yetRegistry produces the same allowedScreens as current sidebar + guards for every role; tested
1Router + sidebar generated from the registry; delete duplicated role logic from NavBarNavBar is a pure renderer over allowedScreens; direct URL to disallowed screens redirects
2Store-lifecycle audit; shared Suspense + chunk-error boundary; React.lazy every major screen; split parent routesDisallowed screen chunks are not requested in the browser; no regression in mission/drone store lifecycles; chunk-load errors recover
3 (opportunistic)Fix UnifiedMap useTrafficSubscription/useAirspaceHealth prop gating; gate ETA worker mount on mission-screen eligibilityScreens requesting a simple map do not subscribe to traffic_service / airspace_service

Phases 4 (map plugins) and 5 (build-time entrypoints) from the audit are dropped unless new evidence justifies them.

Consequences

Positive

  • Closes the SettingsPanel access gap in Phase 0 — before any restructuring risk.
  • One place to reason about screen access; sidebar and router cannot drift.
  • Real reduction in initial bundle size; per-role chunk profiles become measurable.
  • Store-lifecycle audit forces clarity on implicit cleanup behavior that is currently load-bearing.
  • Scope is proportional to the concrete defects in the audit, not a speculative framework.

Negative / Costs

  • Phase 0 touches every screen that currently inlines a role check (≈6 files).
  • React.lazy + Suspense requires a shared fallback and a chunk-error recovery path (stale deploys).
  • Store-lifecycle audit may surface latent bugs that must be fixed before Phase 2 can land.
  • Registry + resolver add a small indirection layer; requires a single lint/test to prevent screens bypassing their loader.

Risks and mitigations

  • Auth bootstrap gating cold startallowedScreens can only be computed after isInitialFetched. Mitigation: show a shell skeleton during auth; start prefetching the landing screen’s chunk as soon as auth resolves.
  • Chunk-load failures after a deployReact.lazy throws on stale hashes. Mitigation: the Suspense error boundary catches ChunkLoadError and hard-reloads once.
  • Global store cleanup assumptions break under lazy routes — cleanup that ran on page switch may no longer fire. Mitigation: the Phase 2 audit moves cleanup inside feature-module unmounts; add targeted tests around mission store lifecycle.
  • Stale allowedScreens on role/tenant changeMitigation: recompute on auth context change; memoize router keyed on the resolved set.

Alternatives Considered

  1. Status quo + stricter page-level guards. Closes the SettingsPanel gap but does not reduce bundle size or eliminate duplicated access logic. Rejected.
  2. React.lazy() without a registry. Delivers bundle wins but leaves access rules scattered across pages. Rejected — does not fix the correctness problem, which is the whole point.
  3. Two separate apps (uncrew-app / atomx-app). Hard bundle separation, but doubles deployment cost and fragments shared screens (Main, UserProfile). Reconsider only if deployment topology demands it.
  4. Full capability-provider + map-plugin framework (earlier draft of this ADR). Over-scoped relative to the actual coupling — useOperatorNotificationStream and ETA worker are already gated, and UnifiedMap needs a prop-gating fix, not a plugin architecture. Rejected as premature.

Validation

  • Functional: a user with only screens {A, C, E} can reach only those; direct URLs to others redirect; sidebar matches router. SettingsPanel specifically blocked for non-admins (Phase 0 regression test).
  • Bundle: chunks for Mission Console, Vehicle Manager, Live Map, Mission Planner are not requested for users without access (browser network panel + Vite build report).
  • Service: restricted users never call simulator_service, atomx_simulation_service, mission_request_service, jurisdiction_service, zone_service.
  • Lifecycle: mission/drone store cleanup still runs on navigation away (Phase 2 tests).

References

Last updated on