feat(web): migrate SPA from lit-html to React 19 + TS + Vite - #324
Merged
Conversation
- Add Vite 6 + TypeScript build replacing esbuild, React 19, React Router 7 - LitBridge wraps unconverted lit-html pages inside React app lifecycle - Shared components: SortableTable, Pagination, FilterForm, StatCard, JsonTree, NodeDisplay, ObserverBadges, RouteTypeBadge, icons, ErrorBoundary, Alerts - Hooks: useAutoRefresh, usePageTitle; utils: api, format, clipboard - i18n via react-i18next mirroring existing translation keys - Native React pages: NotFound, Maintenance; all other routes via LitBridge - Jinja2 shell (spa.html) preserved for navbar, SEO, vendor globals, theme - build.js generates legacy-compatible assets.json from Vite manifest - REACT_MIGRATION.md documents full plan and conversion patterns
Convert every remaining lit-html page to React 19 + TypeScript and wire them directly into the router, removing all LitBridge usage from App.tsx: - Home, CustomPage, Profile, Members, Channels - Advertisements, Messages, Routes, Nodes, NodeDetail - Packets, PacketDetail, PacketGroupDetail, Dashboard, MapPage Pages use the shared React infrastructure (apiGet<T>, useAutoRefresh, usePageTitle, useFormatDateTime, Pagination, FilterForm, SortableTable, NodeDisplay, ObserverBadges, RouteTypeBadge, JsonTree, StatCard, icons). Charts/maps still call window.Chart / window.L / window.QRCode / charts.js globals — these move to react-chartjs-2 / react-leaflet in Phase 3. The old lit-html code in spa/ is intentionally kept as the spa.html fallback (rendered only when the Vite bundle is absent) and is still referenced by 5 web tests; it will be removed in Phase 4. Added IconSatelliteDish, IconRuler, IconHopSpan, IconPathLength icons. Verified: tsc --noEmit clean, npm run build (94 modules), pytest tests/test_web/ (256 passed), pre-commit (passed).
Replace all window.Chart / window.L / window.QRCode globals and the charts.js helper script with bundled React components: - react-chartjs-2: typed config builders in utils/charts.ts (buildLineChart, buildActivityChart, buildStackedBar, buildRoutesTrend, buildRouteDetailStrip + ChartColors, averageRouteTier, routeQualityToTier) and wrappers in components/charts/Charts.tsx (ActivityChart, TrendLineChart, StackedBarChart, RoutesTrendChart, RouteDetailStrip). utils/charts.ts imports chart.js/auto. Wired into Home, Dashboard, Routes. - react-leaflet: MapPage rewritten with MapContainer/TileLayer/Marker/Popup + a useMap MapController for fit-bounds and memoized markers; NodeDetail static hero map with divIcon marker + OffsetCenter. Both import leaflet/dist/leaflet.css. - react-qr-code: replaces window.QRCode in Channels and NodeDetail. Bundling & shell: - Chart.js, Leaflet (+CSS), react-qr-code now bundled by Vite; removed the leaflet/chart.js/qrcodejs vendor <script>/<link> tags from spa.html and their copy steps from build.js (fonts stay vendored). Deleted charts.js. - Moved the Vite CSS bundle (asset_app_css) into <head> before app.css so app.css dark-mode Leaflet overrides win over the bundled leaflet.css. - Dropped the chart globals from the Window type declaration. Tests/docs: - test_caching.py: removed charts.js-specific tests; generic JS-cache tests now target spa/app.js. - Updated charts.js cross-references in collector/routes.py + test_routes.py to point at spa-react/utils/charts.ts. Verified: tsc --noEmit clean, npm run build (153 modules), pytest tests/test_web (255 passed), pre-commit (passed).
The React frontend is complete (Phases 1-3); the lit-html fallback is dead (its vendor globals were removed in Phase 3). Delete it and the scaffolding: - Delete the entire src/meshcore_hub/web/static/js/spa/ lit-html tree, LitBridge.tsx, and legacy.d.ts. - Remove the @legacy alias from vite.config.ts and tsconfig.json. - Remove lit-html and qrcodejs from package.json (both unused now). - Remove the lit-html fallback {% else %} branch from spa.html — the Vite build is now required to serve the UI (no fallback bundle). Tests (fallback no longer exists): - test_home/advertisements/nodes/messages.py: assert the React mount point (id="app") instead of a bundled-or-fallback script tag. - test_caching.py: JS-cache tests are header-only (static JS is bundled into dist/, absent in test env; the middleware sets headers on 404 too); the dist-bundle HTML test drops its fallback branch. Docs: - AGENTS.md: new Frontend (React) section (host-run npm/vite/tsc toolchain, react-chartjs-2/react-leaflet/react-qr-code, CSS load order); clarified the compose-stack rule to exempt frontend tooling. - REACT_MIGRATION.md: Phase 4 complete, final file structure, decisions. Verified: tsc --noEmit clean, npm run build, full pytest (1463 passed, 22 skipped), pre-commit (passed).
Frontend CI (closes the no-coverage gap for the TSX): - ci.yml: new 'frontend' job — npm ci, tsc --noEmit, test:frontend, build. - package.json: engines.node>=20, test:frontend/typecheck scripts. vitest unit + component tests (jsdom, @testing-library/react): - utils/charts.test.ts — tier math + every chart builder. - utils/format.test.ts — parseAppDate, formatNumber, truncateKey, emoji helpers, formatRelativeTime. - components/Navbar.test.tsx — feature-gated nav, custom pages, OIDC/maintenance auth gating. - components/Announcements.test.tsx — banner rendering, ordering, dismiss + sessionStorage (covers behaviour moved out of the Python suite). Navbar → React (full SPA shell): - New Navbar/ThemeToggle/Announcements components + useNavItems hook (shared feature-gated nav for desktop + mobile); nav uses react-router NavLink (client-side nav + auto active) — drops the imperative data-nav-link bridge. - main.tsx single root; App.tsx renders Navbar + Announcements above routed <main>. - spa.html slimmed to SEO/config/footer shell (Jinja2 navbar/banners/theme script removed; #app is a plain div React fills). - Backend: _build_config_json exposes system_announcement/network_announcement. Tests (server-rendered nav/banner assertions → config): - conftest.py: get_app_config() helper (robust __APP_CONFIG__ extraction). - test_features/app/home/pages/dashboard rewritten to assert __APP_CONFIG__; dashboard client-rendered-stats tests replaced with a shell assertion. Docs: REACT_MIGRATION.md Phase 5 (incl. deliberately-skipped react-query/ Storybook/Playwright), AGENTS.md Frontend section. Verified: tsc clean, npm run build, vitest (49 passed), pytest tests/test_web (251 passed), full suite (1459 passed), pre-commit (passed).
…check into make + pre-commit - PacketGroupDetail: render the path-node popover via createPortal(document.body) with position:absolute + document coordinates (rect + scrollX/scrollY) instead of position:fixed with one-shot viewport coords, so it scrolls with the page rather than staying pinned to the viewport. Outside-click/Escape close unchanged (DOM-based). - Makefile: 'make test' now runs pytest then the frontend vitest suite via a new 'test-frontend' target (npm run test:frontend). - pre-commit: add a local 'frontend-typecheck' hook (language: system) running 'npm run typecheck' (tsc --noEmit) on TS/TSX + tsconfig/package(-lock).json changes. - AGENTS.md: document the pre-commit TS gate and that 'make test' includes the frontend. Verified: tsc clean, vitest 49 passed, pre-commit --all-files green, make test (1459 backend passed + 49 frontend passed).
…d UI Migrate the React SPA off the bespoke useApiFetch hook and raw useEffect fetches to TanStack Query: useQuery/useQueries for reads, useMutation + invalidateQueries for writes; a central query-key factory and invalidation helpers mirroring the backend cache_invalidation prefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions); polling via refetchInterval (useAutoRefresh now returns pause state only); RouteCard self-fetches its detail/history, dropping the hand-rolled caches. Adds QueryClientProvider in App, a renderWithProviders test helper, and deletes useApiFetch. Also consolidates recurring UI into reusable components (Breadcrumbs, ListToolbar, Modal/ConfirmDialog, NotFoundState, TimeAgo, Definition, CopyableValue, MeshQrCode, Badges, SectionGroup, PageHeader) and fixes the FilterForm clear button, merged toggle wrappers, and role-aware channels/messages cache keys so client invalidation matches the server. Verified: tsc --noEmit clean, vitest 113 passed, pytest test_web 251 + test_cache 119 passed, pre-commit --all-files green.
…eact-frontend-phase1
Replaces the httpx-based smoke tests in tests/e2e/ with a browser E2E suite
under e2e/, running against a self-contained throwaway stack that never
touches the local development database.
Stack & data isolation (e2e/docker-compose.test.yml):
- Own ephemeral Postgres 17 (schema via the `migrate` service / Alembic),
distinct project name + named volumes, no host DB port, no \${VAR}
interpolation. `make e2e-down` destroys everything.
- OIDC enabled with a known session secret; WEB_AUTO_REFRESH_SECONDS=2 so
polling is assertable; CONTENT_HOME mounts a test markdown page.
- Deterministic seeder (e2e/seed_data.py) run via global setup inside the
collector container: nodes/observers with `area` tags, adverts, messages
on the public (17) + a custom channel, raw packets + path hops keyed to
node prefixes, a route + health/history, profiles + adoptions, and
event_observers rows so the observer filter/badges resolve.
Auth & tests:
- Forged signed `meshcore-session` cookies (e2e/mint_session.py,
itsdangerous) for admin/member identities -> storageState; no real IdP
needed. 31 specs across 12 files cover global nav/theme, profile
menu+edit, home hero, dashboard widgets, list filters/auto-refresh/row
actions, observer toggles, the path-node overlay, map filters +
show-labels, members, markdown pages, and routes add/edit/delete
(persistence, validation, confirm dialog). workers:1 / fullyParallel:false
against the single shared backend; targeted data-testids added to React
components for stable selectors.
Fixes surfaced while running the suite on fresh Postgres:
- Migration 5e3b712ccf10 aborted on Postgres: its route-health backfill
queries the live Route model (which now has max_path_length) before that
column exists. The swallowed error left the transaction aborted, killing
the subsequent alembic_version stamp. Wrapped the backfill in a SAVEPOINT
so a failure rolls back cleanly without blocking the migration.
- Restored the full ABUSE_* env set required by the MQTT broker, and set
the web service's API_KEY to the admin key (admin writes go through the
proxy as a Bearer token).
31/31 passing; tsc (frontend+e2e), pytest (1460), and pre-commit green.
A flake8 B007 fix at commit time renamed the whole observer-specs tuple to (_area, _lat, _lon), but the Node() call below still referenced lat/lon. Those names then resolved to the leaked values from the preceding content_specs loop (last iteration = Delta's None/None), so all observer nodes were seeded without coordinates and dropped from the map (7 -> 3 markers). Underscore only `area` (genuinely unused in that loop); lat/lon are used in Node(), so they stay as loop vars.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…7 docs - Migrate footer, error page, and announcements to React; slim Jinja2 shell to SEO/head/config/fonts/theme-init only - Replace server-side markdown rendering with react-markdown (removes Python `markdown` dependency); custom pages + announcements ship raw markdown to the client - Add heading-anchor deep-links with CSS override for inherited colors - Extract pure helpers from pages (messageHelpers, mapMath, routesHelpers, profileHelpers, packetHelpers, packetGroupHelpers) for unit testability - Add comprehensive vitest suite: 59 test files, 315 tests covering all components, pages, and extracted helpers; global i18next mock + matchMedia stub + AbortController-aware apiMock in test infrastructure - Fix SortableTable test DOM nesting (<th> inside proper <table> context) - Update docs for v0.17.0: upgrading.md release notes, README.md project structure + build description, i18n.md path fix - Delete REACT_MIGRATION.md (migration complete, unreferenced) - Add announcements dismiss-persistence + custom-page 404 E2E specs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Migrates the web UI from the lit-html (functional templates) SPA to a React 19 + TypeScript + Vite SPA. All 15 pages, the navbar, banners, theme toggle, charts, maps, and QR codes are now native React components bundled by Vite — the old
spa/lit-html tree,LitBridge, andwindow.Chart/window.L/window.QRCodeglobals are gone. The Jinja2 shell (spa.html) is now a thin SEO /__APP_CONFIG__/ footer shell; React owns<div id="app">.This is the cumulative
feat/react-frontend-phase1branch: Phases 1–5 of the migration plan, plus two follow-ons that went beyond the original plan:useQuery/useMutation+ a central query-key factory and client-side invalidation helpers that mirror the backendcache_invalidationprefixes).e2e/, replacing the old httpx-basedtests/e2e/Python smoke suite.Why
The lit-html SPA had grown to ~9k lines of untyped imperative JS with hand-rolled fetch/render loops, manual chart/map global lifecycle, and no component test coverage. React + TS gives typed
apiGet<T>, shared reusable components, a real test harness (vitest + Testing Library for components, Playwright for browser E2E), and idiomatic client-side nav (react-routerNavLink).Changes
Frontend stack (
package.json,vite.config.ts,tsconfig.json,build.js,vitest.config.ts)@/alias →spa-react/.build.js: Tailwind → vendor fonts copy →vite build→assets.json(format unchanged from the esbuild era). Chart/map/QR libs are Vite-bundled; only fonts stay vendored.Pages & components (
src/meshcore_hub/web/static/js/spa-react/)pages/*.tsx): Home, Dashboard, Nodes, NodeDetail, Advertisements, Messages, Routes, Packets, PacketDetail, PacketGroupDetail, Channels, MapPage, Members, Profile, CustomPage, NotFound, Maintenance.utils/charts.ts, wrappers incomponents/charts/Charts.tsx.MapPage.tsx,NodeDetail.tsx), bothimport "leaflet/dist/leaflet.css".SPA shell (
Navbar.tsx,ThemeToggle.tsx,Announcements.tsx,hooks/useNavItems.tsx,App.tsx,main.tsx,templates/spa.html)NavLink(client-side nav + auto active class) — the imperativedata-nav-link/#nav-loadingDOM bridge is gone.spa.htmlslimmed to SEO<head>/window.__APP_CONFIG__/ footer / early theme-init script.asset_app_css(bundledleaflet.css) loaded in<head>beforeapp.cssso dark-mode map overrides win._build_config_jsonnow exposessystem_announcement/network_announcement(pre-rendered Markdown) for the React banners.TanStack Query data layer
useQuery/useQueriesfor reads,useMutation+invalidateQueriesfor writes. Central query-key factory and invalidation helpers mirroring the backendcache_invalidationprefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions).RouteCardself-fetches its detail/history.useAutoRefreshnow returns pause state only (polling viarefetchInterval).Deletions
src/meshcore_hub/web/static/js/spa/lit-html tree,LitBridge.tsx,legacy.d.ts,charts.js, and the@legacyalias.package.json; removed vendor chart.js/leaflet/qrcodejs<script>/<link>tags andbuild.jscopy steps.Tests
@testing-library/react):utils/charts.test.ts,utils/format.test.ts,components/Navbar.test.tsx,components/Announcements.test.tsx, plus component tests next to shared components.e2e/): self-contained throwaway stack (e2e/docker-compose.test.yml) with its own ephemeral Postgres 17 and isolated volumes — never touches the dev DB. Forged signedmeshcore-sessioncookies (e2e/mint_session.py) for admin/member identities (no real IdP). Deterministic seeder (e2e/seed_data.py). 31 specs across 12 files cover nav/theme, profile, home, dashboard, list filters/auto-refresh/row actions, observer toggles, path-node overlay, map filters, members, markdown pages, and routes CRUD. Targeteddata-testids added to React components for stable selectors.__APP_CONFIG__(get_app_config()helper) instead of server-rendered nav HTML.CI / tooling
.github/workflows/ci.yml: newfrontendjob —npm ci→tsc --noEmit→test:frontend→build.pre-commit-config.yaml: localfrontend-typecheckhook runningnpm run typecheckon TS/TSX.Makefile:make testnow runs the frontend vitest suite; newmake test-frontend,make e2e-*targets.AGENTS.md: new Frontend (React) section;REACT_MIGRATION.mdmigration plan + status.Incidental fixes surfaced by the E2E suite on fresh Postgres
5e3b712ccf10(route-health backfill) aborted on Postgres because it queried the liveRoutemodel (now hasmax_path_length) before that column existed; the swallowed error left the transaction aborted and killed thealembic_versionstamp. Wrapped the backfill in aSAVEPOINTso a failure rolls back cleanly without blocking the migration.Architecture decisions
@/alias →spa-react/./static/locales/; still exposeswindow.t.@source "../js/"ininput.cssscansspa-react/.Verification
Per-commit, as recorded on the branch:
npx tsc --noEmitcleannpm run buildproducesstatic/dist/+assets.jsonnpm run test:frontend(vitest) — up to 113 component/unit tests passingpytest --no-cov tests/test_web/— rewritten web tests passing; fullpytest --no-cov— 1460+ passed, 22 skippednpx playwright test— 31/31 E2E specs passing (on the throwaway stack)pre-commit run --all-files— cleanBrowser E2E and the Docker image builds are user-run (throwaway stack / manual build per repo policy).
Scope / out of scope
assets.jsonformat unchanged (itsvendormap is now empty).SAVEPOINTwrapper, not a schema edit)._build_config_json(banners) and the migration SAVEPOINT wrap.