Modernise the UI stack - #124
Conversation
npm 12 refuses git dependencies by default, so a fresh install failed.
The fork only existed to render rails into a chosen pane, which upstream
already allows via L.Hotline.renderer({pane}). Updating the runtime
dependencies within their semver ranges picks up fixes and clears the
high-severity audit findings in shipped code; Vue, vue-router and the
newer @types packages are held back as they need Vue 2.7 or a newer
TypeScript. leaflet-path-transform's in-range 1.9 is a rewrite, but it
keeps the _transformPoints used to rotate Box areas, which still render
identically.
(AI assisted using Claude Opus 5.5)
Node 18 and the v3 actions are end-of-life; Node 26 is the newest LTS line, supported until April 2029. Building every push and PR catches broken builds before they reach master, and npm ci builds exactly what the lockfile says. The rsync action moves to 9.0.1, pinned by commit since it receives the deploy key; its inputs are unchanged. (AI assisted using Claude Opus 5.5)
Vue 3 removes $on/$off, `new Component()`, prop mutation and .native. A typed mitt bus, one helper that mounts the draw popup, local copies of the popup's props and an explicit click emit from ObjectInfo let the same call sites work on both versions, so the switch only has to rewrite the mounting helper. Passing the native event keeps .stop/.prevent working on ObjectInfo clicks. (AI assisted using Claude Opus 5.5)
TS 3.9 can't parse current @types packages, and Vue 2.7 backports the Composition API and Vue 3 typings as a stepping stone towards Vue 3. Types are pinned to their last TS 4.9-compatible releases. Target drops to es2020 because webpack 4's parser predates the class fields and static blocks TS emits for esnext, and define semantics stay off for vue-class-component and Leaflet subclasses. lintOnSave is off because the old ESLint TS parser crashes under TS 4.9. vuedraggable has no Vue 3 release; vue-draggable-plus keeps its v-model + @update contract but needs 2.7's Composition API, so the swap happens here. (AI assisted using Claude Opus 5.5)
Vue 2 deep-walks any Leaflet layer put into component data, including the whole L.Map; Vue 3 would proxy them instead, breaking Leaflet's identity-based bookkeeping and the identity match that removes the settings exclude sets. markRaw them, and the large lookup collections, at creation; the component arrays holding them stay reactive. markRaw also replaces the Unobservable wrapper, so the details pane now gets the marker itself and re-clicking the open object no longer re-runs its init (which drew a kept area a second time). Settings was only reactive because AppMap put it in data(), which Vue 3 no longer observes in place, so wrap it in reactive() explicitly; its Proxy still fires change callbacks once per assignment. (AI assisted using Claude Opus 5.5)
BootstrapVue has no Vue 3 support. Its components were mostly thin wrappers adding Bootstrap classes, so native elements with the same markup render the same. Bootstrap 4's own JS plugins need jQuery, so until Bootstrap 5 a small local component handles the search preset dropdowns, the only modal drives Bootstrap's markup itself, and hints use native title tooltips. The Bootstrap 4 CSS stays, so the look is otherwise unchanged. (AI assisted using Claude Opus 5.5)
main.ts called Component.registerHooks() inside initUi(), after the imports had already run the @component decorators, so beforeRouteUpdate was only ever an uncalled method and hash navigation never moved the map. Converting AppMap to an options object would silently turn it into a live guard, and that guard aborts query-only navigations (?q=, ?id=) because the map's moveend handler replaces the route mid-navigation. (AI assisted using Claude Opus 5.5)
vue-class-component and vue-property-decorator have no Vue 3 release, so they would block the switch. Objects the templates never read (the map, controls, layer groups, markers, throttlers) become setup() bindings so neither Vue version observes them; ui.late<T>() marks the ones assigned after creation. ObjectInfo derives its object from its props and drops cached metadata when it changes, so an instance reused by an index-keyed v-for can't show a stale object. Emits are declared so Vue 3 won't also bind listeners on the root as fallthrough attributes. (AI assisted using Claude Opus 5.5)
These relied on Vue 2 and router 3 behaviour that changes on the switch. Vue 3 only tracks writes through the proxy, so user exclude sets must be reactive before init() fills their ids or the hidden count stays at 0. The search link spreads $route.query, as router 4 ignores a query embedded in a path, and router-link's append, a no-op here, is gone. Unparsable map coordinates now fall back to the default view directly: the old replace() to the same route was a duplicate navigation in both routers, so no view was set and map init threw on getCenter(). (AI assisted using Claude Opus 5.5)
Vue CLI 3 is unmaintained, needs --openssl-legacy-provider on current Node, and its webpack 4/Babel toolchain blocks further upgrades. Its ESLint 5 stack goes with it, so linting is unavailable for now; Vite does not type-check, so CI runs tsc first. Output now targets Vite's default browser baseline, without Babel or autoprefixer, and the dev server moves to Vite's port 5173. leaflet-hotline is registered by hand because Vite gets its CommonJS factory, not the self-registering AMD branch webpack took. Vite's bundle is strict-mode ES, where leaflet-draw's assignments to undeclared globals throw and break rectangle drawing and circle resizing, so those globals are created up front. VUE_APP_ stays as the env prefix so existing .env files keep working. public/game_files stays out of dist: it is often a symlink to ~300 MB of game data that the app fetches from VUE_APP_GAME_FILES anyway. (AI assisted using Claude Opus 5.5)
Vue 2 is end-of-life, and the last blockers (BootstrapVue, class components) are gone. Vue 3 mounts inside #app instead of replacing it, so the container needs the full height and App.vue no longer owns the id; beforeDestroy hooks are renamed because Vue 3 silently ignores them. Router 4 navigates asynchronously, so mounting waits for the first route that AppMap reads, and it resolves duplicate navigations instead of rejecting them. Vue 3's whitespace condensing matches Vue CLI's except between elements on one line, so the compat option goes and the one such space is removed. Vue 3 tracks Set mutations, so the filter button reads Settings.shownGroups directly instead of keeping a copy. (AI assisted using Claude Opus 5.5)
Linting had been off since the ESLint 5 stack could not parse modern TypeScript, and it left with Vue CLI. A standalone flat config with typescript-eslint and eslint-plugin-vue's Vue 3 essential preset runs as plain `eslint .`; it also picks up no-var, prefer-const and ESLint 10's newer recommended rules such as no-useless-assignment. With their findings fixed (var and never-reassigned let, dead initial values, a no-op self-assignment) the tree lints clean, so CI can enforce it. The vendored tile workaround is ignored rather than reformatted. (AI assisted using Claude Opus 5.5)
Each of these outlived what it belonged to for the rest of the session: draw popup apps were never unmounted when their layer was deleted, split or replaced, every object view added another ColorScale:change map listener, and Leaflet does not remove a rail's own hotline renderer with the rail, leaving an empty canvas in the tile pane. Persistent rails get a listener tied to the colour scale so they still recolour. (AI assisted using Claude Opus 5.5)
TS 4.9 held the @types packages at old pinned versions. TS 7 type-checks the project, but it has no compiler API yet, so typescript-eslint runs on TS 6 installed as `typescript`, the side-by-side setup the TS team recommends. The tsconfig still carried Vue CLI/Babel-era settings: Vite resolves like a bundler and esbuild transpiles each file on its own, so say so and drop the rest. With no Babel to appease, ES2022 class-field semantics are safe: the only Leaflet subclass with a redeclared field already uses `declare`. Newer TS rejects literals that aren't enum members, so the 0 passed as a no-op mode gets a None member; it also reports the draw event map where a @ts-ignore can't reach, so the map gets a type instead. Newer @types/leaflet makes radius optional, so the CanvasMarker options cast goes. (AI assisted using Claude Opus 5.5)
Bootstrap 4 is end-of-life. v5 dropped or renamed several classes we use (spacing, custom controls, form-group, btn-block, .close) and changed enough defaults to be noticeable on our dark panes, so this also keeps the v4 look where it matters. Without Popper, a menu is only anchored below its toggle with data-bs-popper, so AppDropdown sets it as Bootstrap's static dropdowns do. Toggled buttons sit in a d-grid since display utilities are !important and would defeat v-show. (AI assisted using Claude Opus 5.5)
The hand-rolled dropdown and modal only existed because Bootstrap 4's JS needed jQuery. They lacked keyboard navigation, focus trapping and focus return, and a drag from the modal's inputs onto the backdrop closed it. Bootstrap 5's plugins are the reference behaviour for the markup we render and need only Popper, which also brings back styled tooltips in place of the native title ones. (AI assisted using Claude Opus 5.5)
Font Awesome 5 is end-of-life. FA7 still ships the fa/fas/far prefixes and the v5 icon names as aliases, so no template changes are needed. FA7 makes every icon fixed-width by default, which widened inline and tab icons and shifted buttons; resetting --fa-width keeps FA5's natural widths while fa-fw still gives fixed width. Picked over FA6, whose font metrics made search result rows showing the ⚔ glyph 1px taller. (AI assisted using Claude Opus 5.5)
Vue Router 5 is the current line, and 4.x has had no release since December 2025. v5 folds file-based routing into the core package with no breaking changes for plain v4 usage; the build-time dependencies it pulls in are not bundled, and the app bundle stays the same size. (AI assisted using Claude Opus 5.5)
Immer 10 has had no release since October 2025. 11's one breaking change, iterating only enumerable own properties by default, cannot affect us: we only draft plain GeoJSON objects when splitting a drawn line, which still works. (AI assisted using Claude Opus 5.5)
Vite 8 bundles with Rolldown and transforms/minifies with Oxc and Lightning CSS, which builds several times faster and drops esbuild and Rollup from the dependency tree. Rolldown's namespace import of a CJS module is a snapshot taken before plugins run, so MapBase needs the default leaflet import to see L.RasterCoords; without it the map never initialised. "type": "module" keeps vite.config.ts compatible with the native config loader Vite plans to make the default, which it otherwise warns about on every run. The default build target rises to Chrome/Edge 111, Firefox 114 and Safari 16.4, so Lightning CSS also drops some vendor prefixes. Headless browser checks pass against both the build and the dev server. (AI assisted using Claude Opus 5.5)
pnpm links packages from a shared store instead of copying them into every checkout, and its strict node_modules layout makes an import of an undeclared dependency fail instead of silently working. The lockfile is imported from package-lock.json, so the resolved versions stay the same. packageManager pins the pnpm version for local use and for CI, where the setup action is pinned by commit like the deploy one. (AI assisted using Claude Opus 5.5)
savage13
left a comment
There was a problem hiding this comment.
Looks good to me.
Wow. Nice work (lots of changes)
Updated version works on my machine.
I went through the PR "commit by commit" (in github) and "file by file" (in Reviewable)
cf1065c ok Upgrade to Vite 8
acf5b33 ok Upgrade immer to 11
984254b ok Upgrade vue-router to 5
a280517 ok Upgrade Font Awesome to 7
5cd0d92 ok Use Bootstrap's own dropdown, modal and tooltip JS
b5d8455 ok Upgrade to Bootstrap 5
css styling
ca5ec1c ok Upgrade TypeScript to 7 and modernise tsconfig
889a84c ok Fix draw popup, ColorScale listener and hotline renderer leaks
leaky rails
60461af ok Lint with ESLint 10 and a flat config, and lint in CI
af2352b ok Switch to Vue 3 and vue-router 4
more defineComponent/Component changes
beforeDestroy => beforeUnmount
cd546f5 ok Build with Vite instead of Vue CLI 3
remove openssl, update to Vite
ec77c5a ok Prepare routing and exclude sets for vue-router 4 and Vue 3
ec4286c ok Convert all components to defineComponent and drop decorator libraries
add defineComponents replacing decorators (cleaner)
big commit
0f457dc ok Drop AppMap's beforeRouteUpdate hook, which never ran
2ac3de1 ok Remove BootstrapVue
Add AppDropDown, BootstrapVue removed, b-tagname removed
e947897 ok Keep Leaflet objects out of Vue reactivity and make Settings reactive
markRaw, remove ui.Unobservable (remove .data access)
add reactive to settings, fantastic :)
cd77321 ok Upgrade to TypeScript 4.9 and Vue 2.7
VueDraggable added (vue-draggable-plus)
a8952c3 ok Replace Vue-2-only patterns ahead of the Vue 3 migration
Added AppMapPopupProps, MountedComponents, event hanlding modified
appMapBus created, AppMapPopup modified, mountComponent created
5a3f2d3 ok CI: build every push and PR on Node 26, deploy only from master
d6e282f ok Use leaflet-hotline from npm and update deps within their ranges
@savage13 partially reviewed 60 files and all commit messages, and made 1 comment.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on leoetlino).
The app was built on Vue 2, Vue CLI 3 (webpack 4), TypeScript 3.9, BootstrapVue, Bootstrap 4 and Font Awesome 5. All of those are now end-of-life or unmaintained. We also needed a nasty workaround (
--openssl-legacy-provider) just to make the build toolchain work with newer Node versions.This set of commits migrates the app to Vue 3, Vite 8, TypeScript 7, Bootstrap 5, Font Awesome 7, pnpm while keeping everything in the UI (almost exactly) the same.
The Vue upgrade is the most tedious part because vue-class-component (which we were using for components) doesn't support Vue 3 so most of the changes to the objmap's own code are to migrate to the options API.
AI assisted, since the changes are mainly mechanical but extremely tedious and time consuming to do by hand. Recommend reviewing commit by commit. I've briefly tested this and couldn't find any regressions.
This change is