Skip to content

chore(deps): upgrade the React apps to the latest stable versions (part of #1453) - #1461

Merged
iammukeshm merged 2 commits into
mainfrom
chore/upgrade-frontend-stack
Nov 3, 2026
Merged

iammukeshm merged 2 commits into
mainfrom
chore/upgrade-frontend-stack

Conversation

@iammukeshm

Copy link
Copy Markdown
Member

This is the frontend half of #1453. It moves both React apps (clients/admin, clients/dashboard) to the latest stable versions, moves their build to Node 26 LTS, and fixes what the new majors flagged. The backend half landed in #1460.

Version bumps

Package From To Apps
vite 7.3 8.3.5 both
@vitejs/plugin-react 4.7 6.2.1 both
typescript 5.9 6.0.3 (kept below 6.1 for typescript-eslint) both
eslint 9.39 10.14.0 both
eslint-plugin-react-hooks 5.2 7.1.1 both
eslint-plugin-react-refresh 0.4 0.5.7 both
eslint-plugin-jsx-a11y 6.10.2 replaced by eslint-plugin-jsx-a11y-x 0.2.0 (see below) both
globals 15 17.14.0 both
lucide-react 0.475 1.67.0 both
@types/node 22 26.6.6 both
zod 3.25 4.6.7 admin
@hookform/resolvers 3.10 7.0.2 admin
react / react-dom 19.2 19.3.1 both
react-router-dom 7.18.4 7.18.5 both
@tanstack/react-query 5.99 / 5.100 5.105.3 both
@tanstack/react-virtual 3.13 3.14.26 dashboard
Radix (dialog, dropdown-menu, label, slot) 1.1 / 2.1 / 2.1 / 1.2 1.3.1 / 2.2.1 / 2.1.17 / 1.5.0 both
tailwindcss + @tailwindcss/vite 4.2 / 4.3.0 4.3.4 both
tailwind-merge 3.5 / 3.6 3.7.0 both
@microsoft/signalr 10.0.0 10.0.11 both
react-hook-form 7.72 7.91.0 admin
i18next / react-i18next / i18next-browser-languagedetector 26.3 / 17.0 / 8.2 26.7.1 / 17.1.12 / 8.3.0 both
sonner 2.0.7 2.0.8 both
@playwright/test 1.60 1.64.1 both
typescript-eslint 8.58 / 8.59 8.72.1 both
@types/react / @types/react-dom 19.2 19.3.0 both
autoprefixer 10.5 10.6.2 admin
eslint-plugin-i18next 6.1.5 6.1.5 (already the latest) both
Docker build image node:22-alpine node:26-alpine both
Docker runtime image nginx:alpine (floating) nginx:1.31.8-alpine (pinned; it's what alpine resolves to today) both
Frontend CI Node 22 26

engines.node is now ^20.19.0 || >=22.13.0, which is what Vite 8 and ESLint 10 need. Both lockfiles were regenerated with a plain npm install, with no --legacy-peer-deps and no overrides. The old esbuild override is gone because Vite 8 no longer depends on esbuild, and nothing else pulls it in. (#1460 had already moved setup-node to v7 in this workflow, so the only workflow change here is the Node version.)

What changed in our code, per major

Vite 8 and plugin-react 6. Neither config used manualChunks, rollupOptions, esbuild options or the plugin's babel option, so nothing needed porting to Rolldown. __dirname became import.meta.dirname, which clears the warning about the upcoming native config loader. The admin dev proxy now forwards WebSocket upgrades on /api (ws: true), as the dashboard's already did. Without it, the admin's SignalR hub negotiated over HTTP but its upgrade landed on Vite's own server. The runtime /config.json loading is unchanged, and so is the dashboard's dev middleware that serves it. Production builds take about 0.5 s instead of 5 s.

TypeScript 6.0. baseUrl is deprecated, so I removed it. The paths entries were already ./src/*, so imports resolve the same way. DOM.Iterable is folded into DOM now, so it's gone from lib. Every config already set types, strict, module and target explicitly, so the new defaults change nothing.

ESLint 10. The configs now use defineConfig from eslint/config instead of the deprecated tseslint.config(). The one blocker was eslint-plugin-jsx-a11y: its latest release, 6.10.2, accepts ESLint up to 9 and has had no release since 2024 (jsx-eslint/eslint-plugin-jsx-a11y#1075 has been open since February). I swapped it for eslint-plugin-jsx-a11y-x, the es-tooling fork, which supports ESLint 9 and 10. I compared the two: the fork's recommended rule set is identical, and the only rules it drops are three that upstream had already deprecated (accessible-emoji, label-has-for, no-onchange), none of which we enabled. It is registered under the original jsx-a11y plugin name, so rule ids and existing disable comments don't change, and going back to upstream later is a one-line import change. A comment in both configs explains this.

eslint-plugin-react-hooks 7. The new React Compiler rules reported 79 errors: 66 set-state-in-effect, 6 refs, 5 static-components and 2 use-memo. I fixed all of them. No rule is disabled and no eslint-disable comments were added; three old exhaustive-deps disables went away along the way. The fixes follow react.dev's "You Might Not Need an Effect":

  • Dialogs that reset their form when they open now reset during render (keeping the previous open or target value in state), or in the open/close handler.
  • "Go back to page 1 when a filter changes" now happens in the filter's change handler, or during render where the trigger is a debounced value.
  • Drafts copied from server data are re-seeded during render when the data object changes.
  • Async results such as the 2FA QR code and the confirm-email response are stored with the input they belong to, and the displayed value is derived from that, instead of being cleared in an effect.
  • The inactivity hook copies its props into refs in a layout effect instead of during render, and resets its state in the effect's cleanup.
  • Icons picked at render time (audits, health, overview) are now small module-level components.

A few edge cases now behave slightly differently, all on purpose:

  • Avatar images remember the URL that failed instead of keeping a plain flag. Switching straight back to that URL shows the placeholder without retrying.
  • If the operator hasn't picked a plan, the renew-tenant dialog's selection follows the tenant's current plan.
  • "Clear filters" on the admin users list only resets the page when a filter was actually set.
  • The dashboard profile form tracks its ETag in state instead of refs. The rules it enforces are the same.

The remaining warnings are the existing react-refresh/only-export-components ones, plus one new incompatible-library notice on react-hook-form's watch() in the create-tenant dialog. That one is informational: it says the compiler would skip memoizing that component.

lucide-react 1.x. We used none of the removed brand icons, so every import still resolves (tsc checks this). Icons now get aria-hidden="true" by default. Our icon-only controls already have an aria-label, and no spec relied on icons being exposed.

zod 4 and @hookform/resolvers 7 (admin). .email() and .url() became z.string().trim().pipe(z.email(...)) and .pipe(z.url(...)), so the value is still trimmed before it is validated. { message } became { error }. I checked the schemas against the cases the forms rely on: an empty or invalid URL still reports the URL message first, a non-http scheme reports the scheme message, and a password mismatch still lands on confirmPassword. The messages still come from t(), so the pt-BR messages still show. While there, confirmPassword now uses the existing create.validation.min8 message; it used to fall back to zod's built-in English text.

Exceptions

  • TypeScript 7.0.2 is out, but typescript-eslint 8.72.1 requires typescript <6.1.0, so we stay on 6.0.3, as Upgrade the whole stack to the latest stable versions (.NET 10.0.14, Aspire 13.6, Vite 8, ESLint 10, zod 4, Node 26) #1453 decided. TypeScript is the only package npm outdated lists in either app.
  • eslint-plugin-jsx-a11y: replaced by the fork, as explained above.
  • npm audit with dev dependencies shows 12 moderate findings in each app. They all trace back to one advisory, minimatch <11.0.1 (GHSA-g3hv-75wg-m249), which comes in through ESLint 10 itself, typescript-eslint and the a11y plugin. No fixed release exists yet, it only affects lint tooling, and npm audit --omit=dev is clean.
  • template-smoke.yml still builds the scaffolded apps on Node 22. That still meets engines, and that workflow is outside this PR.

Verification

"Before" is origin/main. "After" is this branch merged with current main, which includes #1456 and #1459. Local Node is 24.13.

admin before admin after dashboard before dashboard after
npm ci clean clean clean clean
npm outdated 33 entries typescript 7 only 30 entries typescript 7 only
npm audit --omit=dev 1 high (zod) 0 0 0
npm run lint 0 errors / 8 warnings 0 errors / 8 warnings 0 errors / 14 warnings 0 errors / 12 warnings
tsc -b --noEmit clean clean clean clean
npm run build passes passes passes passes
Playwright (full suite) 179 / 179 183 / 183 248 / 248 251 / 251

The suites have more tests now because main gained specs through the merge. In one full admin run, billing/invoice-detail.spec.ts:76 timed out under load. Run on its own it passed 21/21 (7 tests × 3 repeats), and the next full run was 183/183.

Docker: both images build on node:26-alpine and nginx:1.31.8-alpine. /config.json is rendered from the environment, including the dashboard's FSH_DEFAULT_LANGUAGE. / returns 200, and after docker restart the container reports true 0 (running, no restarts). The Vite 8 dev servers start with no backend and load the login page (Playwright starts them for every run).

Bundle size (gzip)

before after
admin, all JS 381.6 kB in 64 chunks 418.3 kB in 99 chunks
admin, initial load (entry plus its static imports) 225.5 KiB in 1 file 247.5 KiB in 44 files
dashboard, all JS 475.8 kB in 94 chunks 511.0 kB in 156 chunks
dashboard, initial load 226.1 KiB in 1 file 251.7 KiB in 67 files
CSS (admin / dashboard) 19.1 / 21.8 kB 19.3 / 22.0 kB

The JS is about 8 to 11% larger. Most of the difference comes from how Rolldown splits chunks. Rollup put everything the entry needed into one file. Rolldown splits it into many shared chunks, and each one is gzipped separately. In admin, zod 4 also adds about 26 kB, but only in the lazy-loaded form chunks. The minifier isn't the cause: building with esbuild instead of Oxc made the bundle bigger (510.8 KiB). Putting all lucide icons into one chunk brought the dashboard down to 89 files and 481 KiB in total, but initial load didn't change, so I didn't add that config. Chunk tuning is a reasonable follow-up, but it doesn't need to block this upgrade.

Part of #1453

Both clients move to Vite 8 (Rolldown) with @vitejs/plugin-react 6,
TypeScript 6.0, ESLint 10, eslint-plugin-react-hooks 7, lucide-react 1,
globals 17 and @types/node 26, plus every minor and patch bump. Admin
moves to zod 4 and @hookform/resolvers 7.

- tsconfig: drop the deprecated baseUrl (paths were already relative)
  and the now-empty DOM.Iterable lib.
- vite.config: import.meta.dirname instead of __dirname; the admin dev
  proxy forwards WebSocket upgrades for the SignalR hub.
- eslint: defineConfig from eslint/config; eslint-plugin-jsx-a11y has no
  ESLint 10 release, so use the eslint-plugin-jsx-a11y-x fork under the
  original plugin name (same recommended rules).
- Fix every react-hooks 7 error instead of disabling the rules: state
  resets move out of effects into handlers or render-time adjustment,
  refs are no longer touched during render, and icon components chosen
  at render time become module-level components.
- zod 4: z.email()/z.url() via pipe, { error } instead of { message }.
- Drop the esbuild override; Vite 8 no longer depends on esbuild.
- Node 26 LTS in the Dockerfiles and Frontend CI (setup-node v7), nginx
  pinned to 1.31.8-alpine, engines.node raised to Vite 8 / ESLint 10's
  floor.

Part of #1453
…d-stack

# Conflicts:
#	clients/admin/src/pages/login.tsx
#	clients/dashboard/src/pages/login.tsx
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