Skip to content

feat(web): migrate SPA from lit-html to React 19 + TS + Vite - #324

Merged
jinglemansweep merged 14 commits into
mainfrom
feat/react-frontend-phase1
Jul 22, 2026
Merged

feat(web): migrate SPA from lit-html to React 19 + TS + Vite#324
jinglemansweep merged 14 commits into
mainfrom
feat/react-frontend-phase1

Conversation

@jinglemansweep

Copy link
Copy Markdown
Contributor

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, and window.Chart / window.L / window.QRCode globals 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-phase1 branch: Phases 1–5 of the migration plan, plus two follow-ons that went beyond the original plan:

  • TanStack Query data layer (useQuery/useMutation + a central query-key factory and client-side invalidation helpers that mirror the backend cache_invalidation prefixes).
  • Playwright E2E suite under e2e/, replacing the old httpx-based tests/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-router NavLink).

Changes

Frontend stack (package.json, vite.config.ts, tsconfig.json, build.js, vitest.config.ts)

  • React 19, react-router 7, react-i18next, react-chartjs-2, react-leaflet, react-qr-code, chart.js, leaflet, vite 6, TanStack Query.
  • Strict TS with @/ alias → spa-react/. build.js: Tailwind → vendor fonts copy → vite buildassets.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/)

  • All 15 pages converted to native React (pages/*.tsx): Home, Dashboard, Nodes, NodeDetail, Advertisements, Messages, Routes, Packets, PacketDetail, PacketGroupDetail, Channels, MapPage, Members, Profile, CustomPage, NotFound, Maintenance.
  • ~40 shared components (Pagination, FilterForm, SortableTable, StatCard, NodeDisplay, ObserverBadges, RouteTypeBadge, JsonTree, Modal/ConfirmDialog, Breadcrumbs, ListToolbar, PageHeader, MeshQrCode, TimeAgo, etc.) + an icon set.
  • Charts: typed config builders in utils/charts.ts, wrappers in components/charts/Charts.tsx.
  • Maps: react-leaflet (MapPage.tsx, NodeDetail.tsx), both import "leaflet/dist/leaflet.css".

SPA shell (Navbar.tsx, ThemeToggle.tsx, Announcements.tsx, hooks/useNavItems.tsx, App.tsx, main.tsx, templates/spa.html)

  • Navbar, banners, and theme toggle moved to React; nav uses NavLink (client-side nav + auto active class) — the imperative data-nav-link / #nav-loading DOM bridge is gone.
  • spa.html slimmed to SEO <head> / window.__APP_CONFIG__ / footer / early theme-init script. asset_app_css (bundled leaflet.css) loaded in <head> before app.css so dark-mode map overrides win.
  • Backend _build_config_json now exposes system_announcement / network_announcement (pre-rendered Markdown) for the React banners.

TanStack Query data layer

  • useQuery/useQueries for reads, useMutation + invalidateQueries for writes. Central query-key factory and invalidation helpers mirroring the backend cache_invalidation prefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions). RouteCard self-fetches its detail/history. useAutoRefresh now returns pause state only (polling via refetchInterval).

Deletions

  • Entire src/meshcore_hub/web/static/js/spa/ lit-html tree, LitBridge.tsx, legacy.d.ts, charts.js, and the @legacy alias.
  • Removed lit-html + qrcodejs from package.json; removed vendor chart.js/leaflet/qrcodejs <script>/<link> tags and build.js copy steps.

Tests

  • vitest (jsdom, @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.
  • Playwright E2E (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 signed meshcore-session cookies (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. Targeted data-testids added to React components for stable selectors.
  • Python web tests rewritten: navbar/banner assertions now target the embedded __APP_CONFIG__ (get_app_config() helper) instead of server-rendered nav HTML.

CI / tooling

  • .github/workflows/ci.yml: new frontend job — npm citsc --noEmittest:frontendbuild.
  • pre-commit-config.yaml: local frontend-typecheck hook running npm run typecheck on TS/TSX.
  • Makefile: make test now runs the frontend vitest suite; new make test-frontend, make e2e-* targets.
  • AGENTS.md: new Frontend (React) section; REACT_MIGRATION.md migration plan + status.

Incidental fixes surfaced by the E2E suite on fresh Postgres

  • Migration 5e3b712ccf10 (route-health backfill) aborted on Postgres because it queried the live Route model (now has max_path_length) before that column existed; the swallowed error left the transaction aborted and killed the alembic_version stamp. Wrapped the backfill in a SAVEPOINT so a failure rolls back cleanly without blocking the migration.

Architecture decisions

  • TypeScript strict, @/ alias → spa-react/.
  • Jinja2 shell preserved — server renders SEO meta + config JSON; React owns the app mount.
  • react-i18next loads the same locale JSONs from /static/locales/; still exposes window.t.
  • DaisyUI + Tailwind v4 unchanged; @source "../js/" in input.css scans spa-react/.
  • The Vite build is required — there is no fallback bundle (the lit-html fallback was removed in Phase 4).

Verification

Per-commit, as recorded on the branch:

  • npx tsc --noEmit clean
  • npm run build produces static/dist/ + assets.json
  • npm run test:frontend (vitest) — up to 113 component/unit tests passing
  • pytest --no-cov tests/test_web/ — rewritten web tests passing; full pytest --no-cov — 1460+ passed, 22 skipped
  • npx playwright test — 31/31 E2E specs passing (on the throwaway stack)
  • pre-commit run --all-files — clean

Browser E2E and the Docker image builds are user-run (throwaway stack / manual build per repo policy).

Scope / out of scope

  • No API contract changes; assets.json format unchanged (its vendor map is now empty).
  • No DB schema change (the migration fix is a SAVEPOINT wrapper, not a schema edit).
  • Python backend unchanged except _build_config_json (banners) and the migration SAVEPOINT wrap.

- 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.
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

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 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
@jinglemansweep
jinglemansweep merged commit 4ace88d into main Jul 22, 2026
5 checks passed
@jinglemansweep
jinglemansweep deleted the feat/react-frontend-phase1 branch July 22, 2026 22:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant