Modernizing Network-View - six years of frontend debt, paid down in four phases
The Network-View UI is the largest single codebase in Spider: about 1,800 files of React, with ~35 collection modules, a handful of D3 visualisations, a Cytoscape-based network map, and the grids, filters, dashboards, and timeline that make it the analyst-facing surface.
Until April 2026 it was also six years behind on its dependencies.
This release ships the modernisation.
- React 16 → 18. (React 19 dependencies bring performance issue)
- MUI v4 → v7.
- Classic Redux
connect()→ Redux Toolkit. - Webpack → Rspack.
- Moment → Luxon.
~1,800 files touched, hundreds of imports rewritten, hundreds of *Container.js files deleted.
Starting state
The pre-modernisation snapshot, taken in early April 2026 on v14.4.1, was unflattering:
| Concern | Pre-migration | Status |
|---|---|---|
| React | 16.14 | End of life |
| Material-UI | v4 (@material-ui/*) | End of life |
| State management | Classic Redux connect() + ~328 *Container.js wrappers | Functional but verbose |
| Build tool | Webpack 5 + Babel | ~160 s cold build |
| Date library | Moment + moment-timezone | Maintenance mode |
| Class components | ~24 still using ES6 class syntax | Modernisation needed for hooks |
| TypeScript | None | - |
| Linting | None | - |
| Automated tests | 0% | - |
Each item on the list was solvable in isolation.
Doing all of them together, in a hand-crafted sequence, was the project.
The whole plan was written upfront.
Five major phases, each a feature branch, each merged when its E2E suite passed.
Phase 0 - cleanup and safety net
A modernisation pass without tests is a coin flip. So nothing real happened until two things were in place.
Playwright E2E coverage.
Critical user journeys - login, team / user / whisperer selection, screens switch, detail view tabs, dark mode, filter panel, network map render, multi-select - written as a Playwright suite.
This is the regression gate every subsequent phase runs against.
ESLint + Prettier with lint-staged.
Lint runs on changed files only - the codebase was too large to retrofit at once, but every new commit lands clean.
Phase 1 - React 18 + MUI v5
A single branch, coordinated migration. React 18 first, then MUI v5 - sequential so that if anything broke, the cause was isolated.
The numbers from the codebase audit:
- 409 files used
makeStyles, 23 usedwithStyles - 805+ files imported from
@material-ui/* - 2 files used
ReactDOM.render(replaced withcreateRoot)
The strategy for MUI was deliberately conservative: upgrade to v5 with the @mui/styles compat layer in place, keeping makeStyles/withStyles working unchanged. The full styling migration was deferred. This kept the diff for Phase 1 focused on the version bump itself - 805 import rewrites, the breaking variant/disableElevation/Hidden changes, theme palette.type → palette.mode - rather than mixing it with hundreds of styling rewrites.
Third-party libraries that did not survive React 18:
react-motion→ replaced byframer-motion(peer dep was capped at React 16)react-notifications-component→ 4.xreact-select→ 5.xreact-redux→ 8.xredux-devtools-extension→ removed (use the browser extension directly)
Phase 1.5 - styled() everywhere
Once the app was running on MUI v5, the compat layer was repaid: ~393 makeStyles files and ~23 withStyles files were converted to MUI's styled() API. Function components and class components, MUI overrides and regular components - all converted.
This was the slow phase. Most files were mechanical conversions, but a non-trivial fraction needed visual regression review - dark mode in particular surfaced a handful of d3-color stringification issues and button-state mismatches that no test could catch.
Removing the compat layer (StyledEngineProvider, LegacyThemeProvider) at the end of this phase was the moment the app was actually on MUI v5 rather than a hybrid.
Phase 1.6 - MUI v7 (skipping v6)
With @mui/styles gone, the path to v7 was open. The pre-flight audit said:
- ~38 files used
InputProps/InputLabelProps - ~32 files used the legacy
classes={{...}}prop - 0 Grid usages (the biggest v6/v7 breaker)
- 0 date pickers, 0
@mui/lab
The staged approach was: migrate the deprecated APIs (InputProps → slotProps, classes → className / sx / slotProps) while still on v5, since both styles work in v5. Then bump the version. This separated two classes of failure - API migration bugs vs version-bump bugs - so each was easier to isolate.
After v7 landed, the @mui/styles package was removed entirely, and the webpack aliases for @material-ui/* were deleted from the build config. The codebase no longer references the v4 namespace anywhere.
Phase 2 - Redux Toolkit + a three-layer split
The previous Redux pattern was the standard 2-file shape: a Foo.jsx presentational component plus a FooContainer.js with mapStateToProps, mapDispatchToProps, and a connect() call. ~335 containers were in the codebase.
The migration introduced a three-layer architecture:
- Presentational component -
Foo.jsx. Untouched by the migration. This is what made the sweep low-risk: the pure UI layer was already separated and could stay as it was. - Custom hooks -
useFooData(wrapsuseSelector) anduseFooActions(wrapsuseDispatch). These own all Redux coupling and can be tested independently of the UI. - Connected wrapper - a small component that calls both hooks and spreads the result onto the presentational component.
Hooks + wrapper land in a new FooConnected.jsx file. Importers update '…/FooContainer' → '…/FooConnected'. The old FooContainer.js is deleted.
The approach was hybrid: a jscodeshift codemod handled the trivial cases (single mapStateToProps of plain fields, single mapDispatchToProps binding action creators, no mergeProps, no ownProps-dependent selection). The non-trivial cases - factory HOCs, ownProps-driven selectors, mergeProps - were hand-migrated to the same shape.
The same branch also migrated src/actions/ + src/reducers/ to Redux Toolkit createSlice, swapped createStore for configureStore, and renamed saga action-type references to slice.actions.x.type. Sagas themselves stayed - they remain the side-effect layer.
One subtle compatibility trick mattered: each createSlice used prepare / explicit type overrides so generated action types matched the existing legacy strings (MENU_OPEN, etc.) until the final saga-rename commit. This let slices land one at a time without breaking running sagas.
Final tally for Phase 2:
- All ~335
connect()call sites replaced - ~354
*Container.jsfiles deleted, ~316*Connected.jsxfiles created - 32 actions files + 30 reducers files consolidated into RTK slices
Phase 3 - five incremental improvements on one branch
Phase 3 was a bundle of five independent steps, each self-contained and committed separately so any could be reverted with git revert without touching the others.
Webpack → Rspack + TypeScript tooling
The headline: Rspack (Rust-based webpack drop-in) replaced Webpack 5 + Babel, with builtin:swc-loader taking over JSX transforms. Cold builds dropped from ~160 s to roughly 10× faster.
The dev server stayed working: webpack-hot-middleware + webpack-dev-middleware were swapped for their Rspack equivalents (rspack-hot-middleware, @rspack/dev-middleware), ReactRefreshWebpackPlugin for @rspack/plugin-react-refresh. The Koa adapter that wires the dev middleware into the server was unchanged.
Two things had to be migrated because SWC does not run Babel plugins:
babel-plugin-lodashwas load-bearing for bundle size. Replaced by switching tolodash-es(lodash compiled as ESM), which Rspack + SWC tree-shake natively. 340 import sites rewritten, zero API changes.@babel/plugin-proposal-export-default-from(the non-standardexport Foo from './Foo'syntax) - replaced with the standardexport { default as Foo } from './Foo'form.
TypeScript tooling was added at the same time, for new files only. No existing .jsx was converted in bulk: allowJs: true, checkJs: false, strict: false. New .tsx files get full IDE support and are checked by tsc --noEmit as part of lint. Wholesale .jsx → .tsx conversion is opportunistic - it happens when a file is being touched anyway.
npm dependency upgrades
npm outdated produced a long list. Minor / patch bumps landed as one commit. Major bumps landed one package at a time, each with its own changelog review and smoke test:
react-markdown7 → 10 (breaking:childrenprop removed)react-tabs3 → 6 (prop renames)react-ace,react-dropzone,react-syntax-highlighter,react-cytoscapejs,react-notifications-component- all bumpedimmer9 → 10 (named export change)filesize8 → 9 (named export change)
react-redux was held at 8.x because the 9.x bump depends on React 19 - deferred to Phase 4.
Unused React imports
With the automatic JSX runtime in place, import React from 'react' is dead code in every file that doesn't reference React.* APIs directly. react-codemod update-react-imports swept ~500 files in one commit.
Moment → Luxon
122 files imported moment or moment-timezone. Moment is in maintenance mode, mutates date objects, and weighs 67 KB gzipped. Luxon is immutable, tree-shakeable, and well-maintained.
The migration was not a simple s/moment/luxon/ - format tokens differ (YYYY → yyyy, DD → dd, ddd → EEE), method names differ (.format() → .toFormat(), .unix() → .toUnixInteger(), .valueOf() → .toMillis()), and comparison APIs differ (.isBefore(x) becomes a plain <).
src/config/locale.js held the format constants used everywhere - those had to be audited token-by-token before the sweep. Then 119 remaining files were converted area-by-area (apploader, models, sagas, timeline components, collection timelines, locale, public links). The moment and moment-timezone packages were dropped once zero imports remained.
10 class components converted to hooks
The codebase still had ~24 class components, but most were tightly coupled to imperative D3 or Cytoscape rendering and were left alone (ErrorBoundary, GridHeader, NetworkMap, the timelines, the sequence diagrams). 10 were tractable:
App.jsx, VerticalPanels.jsx, FullScreenMap.jsx, Grid.jsx, StringField.jsx, ArrayOfStringsField.jsx, ArrayOf2StringsFieldsAndOptions.jsx, QueryField.jsx, Histo.jsx, LuceneEdit.jsx.
One new shared hook came out of App.jsx: usePanelSize(ref) wraps the ResizeObserver pattern that was duplicated three times (top panel, bottom panel, middle panel). App no longer manages those observers itself; each panel content component owns its own size.
defaultProps (removed in React 19) was replaced with ES6 default parameter values in each function signature - avoiding a separate cleanup pass later.
Phase 4 - React 19, then back to React 18
Phase 4 was supposed to be the closing chapter: React 18 → 19, react-redux 8 → 9, Redux 4 → 5, plus the React 19 cleanups (forwardRef wrappers removed since ref is a plain prop in React 19; propTypes and defaultProps removed since React 19 dropped runtime support).
The cleanups landed cleanly:
- 5
forwardRefwrappers removed (FlatButton, FloatingButton, RaisedButton, InfiniteScrollContainer, HostShortView's Label) - A scripted bulk pass removed
propTypesfrom 336 files and dropped theprop-typespackage - The one remaining
Component.defaultProps(inGridHeader.jsx) was converted to destructuring defaults
Then we deployed React 19. The UI was visibly slower.
The result was not encouraging. We tried narrowing selectors, adding useMemo / useCallback to the connected wrappers for GridConnected, GridOptionsConnected, and TimeLineLibConnected. It helped, but the baseline was still worse than React 18.
I found out that the UI was leaking memory at high pace. In not time the memory usage was reaching 4GB!
The bug is in React-Redux linked to React 19.
So I reverted. React 18.3.1 stayed. The forwardRef wrappers were restored on the five files. The propTypes removal and defaultProps cleanup were kept - those were strict improvements regardless of React version. The perf optimisations on the three Connected wrappers were kept too - they made React 18 faster than it had been.
This release ships React 18, not 19. The Phase 4 work is not wasted - the codebase is now ready for React 19 the next time I want to try, and I have a baseline performance profile that gives us a better starting point for the investigation.
Final stack
After all four phases:
| Concern | Before | After |
|---|---|---|
| React | 16.14 | 18.3.1 |
| Material-UI | v4 | v7.3.10 |
| State management | classic connect() + Containers | Redux Toolkit + 3-layer hooks split |
| Build tool | Webpack 5 + Babel | Rspack + SWC (~10× faster) |
| Date library | Moment + moment-timezone | Luxon |
| Lodash | lodash (CommonJS) | lodash-es (tree-shakeable) |
| Class components | ~24 | ~14 (rest deliberately kept on D3/Cytoscape) |
| TypeScript | None | Tooling in place (.tsx opportunistically) |
| Linting | None | ESLint 9 flat config + Prettier |
| Automated tests | 0% | Playwright E2E on critical user journeys |
Observations
A few things stood out across the four phases.
The plan-on-disk worked.
FRONTEND_MODERNIZATION.md was kept in the repo, kept up to date with completed-checkbox status, and referenced from every phase's design spec. New sessions could start cold and find their footing in minutes. The same pattern that worked on the Controller and on the parsing pipeline migrations worked here.
The presentational / connected split paid off twice.
It was already useful before the migration: pure components were easy to reason about. It became invaluable during Phase 2 - the presentational .jsx files were not modified at all by the Redux modernisation, which made the diff for ~335 components reviewable rather than terrifying.
Performance is not monotone with version.
React 19's headline improvements are real, but they did not net out positive on this codebase. The honest finding is part of the release story.
The compat layers were worth the cost.
@mui/styles carried the codebase through Phase 1 → Phase 1.5 → Phase 1.6 without forcing a styling rewrite mid-migration.
Splitting "version bump" from "API migration" - both for React and for MUI - kept each step's risk surface small.
Build speed compounds.
Cutting cold builds by ~10× changed how often the dev server got restarted, which changed how willing the human in the loop was to make speculative changes, which changed how much the AI in the loop got to iterate before context filled up.
Closing
The Network-View v15.0 release that ships with 2026.05.13 is the visible side of all of this.
From a user's standpoint, the only changes are the UI redesign, the new visualisations, and the multi-protocol view. The whole modernisation pass is invisible.
That is the point. The user-visible improvements are easier to ship now because the codebase underneath them is no longer fighting against them. The next round of feature work will run on Rspack-fast iteration loops, hook-based components, slice-based state, and styled() everywhere. The debt is paid.
Feedback on Network-View regressions is welcome - we expect we'll find a few more visual edge cases that the screenshot diff missed.
There is one drawback that was delivered with React 18: batch rendering.
This prevent direct control on the rendering, and simultaneous rendering.
This is fine for performance, but leads to ugly effects when resizing panels 😕!