Modular Screen Architecture for uncrew-apollo-frontend (ADR)
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-04-22 |
| Author | Oleksii Naboichenko |
| Deciders | Apollo frontend team |
| Related | Screen 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:
- Screen availability ≠ screen loading. Every major screen is eagerly imported (src/pages/index.tsx:5-21); no
React.lazy/Suspenseexists anywhere in the codebase. Vite only splitsreact/react-dom/react-router(vite.config.ts:36). SettingsPanelhas no role guard at all (src/pages/SettingsPanel/index.tsx:12) — every other major screen hand-rollsisAdmin || isSupervisor ? <Screen/> : <Navigate/>(e.g. MissionConsole/index.tsx:63, VehicleManager/index.tsx:145). This is the most acute correctness gap.- No access-control primitive exists. Each page re-implements its own role check. There is no
useCanAccess,<ScreenGate/>, or central registry. - Auth bootstrap is the cold-start critical path.
useUserStore.isInitialFetchedblocks 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. - Global Zustand stores carry implicit lifecycle assumptions.
useMissionsListStore,useUAVInventoryStore,useMapStateStore,useShiftStoreare initialized app-wide and several features rely on page-switch cleanup. IntroducingSuspenseboundaries naively will break these silently. - Some “shell-global” coupling is overstated.
useOperatorNotificationStreamis already internally role-gated (src/hooks/useOperatorNotificationStream.ts:122); the ETA worker short-circuits on an empty missions store.UnifiedMaptraffic behavior is already prop-driven (trafficConfig/isTrafficLayerEnabled) — the concrete defect is thatuseTrafficSubscription()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:
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.- Central screen registry — one
ScreenDefinition[]drives routes, sidebar, role/flag/product-mode checks, and the lazy loader for each screen. allowedScreensresolved after auth bootstrap —Set<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.- Per-screen lazy loading with a shared Suspense + error boundary —
React.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. - Store-lifecycle audit before lazy loading ships. Explicit pass over
useMissionsListStore,useUAVInventoryStore,useMapStateStore,useShiftStoreto document initialization, teardown, and page-switch cleanup — and move cleanup inside feature modules where it belongs. - Feature modules — code reorganized under
src/modules/{shared,uncrew,atomx}/<feature>/, colocating route, local providers, stores, queries, and tests. Today’ssrc/features/directory contains only helpers and will be absorbed. - Targeted fixes instead of a capability framework. Do not build a generic
Capabilityabstraction yet:- Fix
UnifiedMapby makinguseTrafficSubscription/useAirspaceHealthconditional on their config props. - Hoist the
isOperatorcheck to the mount site ofuseOperatorNotificationStream(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.
- Fix
- Deferred / dropped from earlier drafts:
- Build-time product entrypoints — dropped. Runtime lazy loading plus tenant allowlist meets the stated goal without doubling CI cost.
UnifiedMapplugin decomposition — descoped to the prop-gating fix above. A plugin framework is speculative.- Generic
CapabilityProviderslayer — 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
| Phase | Deliverable | Exit criteria |
|---|---|---|
| 0 | useCanAccess / <ScreenGate/> primitive; migrate all inline role checks; add guard to SettingsPanel | No page hand-rolls a role check; SettingsPanel direct URL blocked for non-admins; unit tests on the primitive |
| 0.5 | Central registry encoding today’s rules; resolution runs inside the isInitialFetched gate; no behavior change yet | Registry produces the same allowedScreens as current sidebar + guards for every role; tested |
| 1 | Router + sidebar generated from the registry; delete duplicated role logic from NavBar | NavBar is a pure renderer over allowedScreens; direct URL to disallowed screens redirects |
| 2 | Store-lifecycle audit; shared Suspense + chunk-error boundary; React.lazy every major screen; split parent routes | Disallowed 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 eligibility | Screens 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
SettingsPanelaccess 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+Suspenserequires 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 start —
allowedScreenscan only be computed afterisInitialFetched. Mitigation: show a shell skeleton during auth; start prefetching the landing screen’s chunk as soon as auth resolves. - Chunk-load failures after a deploy —
React.lazythrows on stale hashes. Mitigation: the Suspense error boundary catchesChunkLoadErrorand 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
allowedScreenson role/tenant change — Mitigation: recompute on auth context change; memoize router keyed on the resolved set.
Alternatives Considered
- Status quo + stricter page-level guards. Closes the
SettingsPanelgap but does not reduce bundle size or eliminate duplicated access logic. Rejected. 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.- 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. - Full capability-provider + map-plugin framework (earlier draft of this ADR). Over-scoped relative to the actual coupling —
useOperatorNotificationStreamand ETA worker are already gated, andUnifiedMapneeds 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.
SettingsPanelspecifically blocked for non-admins (Phase 0 regression test). - Bundle: chunks for
Mission Console,Vehicle Manager,Live Map,Mission Plannerare 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
- src/pages/index.tsx — current route tree, auth-ready gate at line 27
- src/pages/SettingsPanel/index.tsx — unguarded page (Phase 0 target)
- src/layout/Sidebar/NavBar/index.tsx — current sidebar filtering
- src/config/environment.ts — product mode + flags
- src/config/routes.ts — existing
ROUTES/ROUTES_IDS - src/components/UnifiedMap/UnifiedMap.tsx —
useTrafficSubscriptionunconditional call at line 233 - src/hooks/useOperatorNotificationStream.ts — already role-gated at line 122
- vite.config.ts — current chunk config (line 36)
Last updated on