Web upgrade: Next 16 + React 19 + MSAL v4 (#114)

* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3

react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): react 19 / msal v4 / next 16 code adaptations

- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
  storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
  dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
  (all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
  jumping instead of animating (Next 16 stopped suppressing CSS
  smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
  webpack-only analyzer, use --webpack for ANALYZE runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): untrack Next-managed next-env.d.ts, generate types in CI

Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): migrate lint script to eslint CLI (next lint removed in 16)

Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-30 23:54:12 +02:00
committed by GitHub
parent 1593232898
commit cf45911b69
18 changed files with 691 additions and 257 deletions
+5
View File
@@ -69,6 +69,11 @@ jobs:
- run: npm ci
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
# v16); typegen generates them so tsc sees the same types as a build.
- name: Generate Next.js types
run: npx next typegen
- name: Typecheck
run: npx tsc --noEmit
+3
View File
@@ -53,6 +53,9 @@ __azurite_db*__.json
node_modules/
.next/
out/
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
# inside flips between .next/types and .next/dev/types) — must not be tracked.
next-env.d.ts
build/
dist/
!rules/dist/
@@ -232,7 +232,7 @@ function HomeContent() {
// Auto-load ALL remaining sessions when search is active and local results are insufficient.
// Uses a 500ms debounce so rapid typing doesn't trigger unnecessary loads.
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout>>();
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (autoLoadTimerRef.current) clearTimeout(autoLoadTimerRef.current);
+4 -1
View File
@@ -113,8 +113,11 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// data-scroll-behavior: Next 16 no longer auto-suppresses CSS smooth-scroll
// during route navigation; without the opt-in, globals.css's
// `scroll-behavior: smooth` would animate every route change to top.
return (
<html lang="en">
<html lang="en" data-scroll-behavior="smooth">
<body className={inter.className}>
<script
type="application/ld+json"
@@ -25,7 +25,7 @@ interface UseProgressEventsParams {
export interface UseProgressEventsReturn {
events: EnrollmentEvent[];
setEvents: React.Dispatch<React.SetStateAction<EnrollmentEvent[]>>;
sessionRef: React.MutableRefObject<Session | null>;
sessionRef: React.RefObject<Session | null>;
scheduleFetchEvents: (delayMs?: number) => void;
}
@@ -15,7 +15,7 @@ interface SignalRApi {
interface UseProgressSignalRParams {
session: Session | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionRef: React.RefObject<Session | null>;
signalR: SignalRApi;
scheduleFetchEvents: (delayMs?: number) => void;
}
@@ -46,8 +46,8 @@ export interface UseSessionDetailReturn {
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
fetchSessionDetails: () => Promise<void>;
resolveEffectiveTenantId: () => string | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionIdRef: React.MutableRefObject<string>;
sessionRef: React.RefObject<Session | null>;
sessionIdRef: React.RefObject<string>;
}
/**
@@ -63,7 +63,7 @@ interface UseSessionEventsParams {
sessionTenantId: string | null;
sessionStatus: string | null | undefined;
resolveEffectiveTenantId: () => string | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionRef: React.RefObject<Session | null>;
fetchSessionDetails: () => Promise<void>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
isConnected: boolean;
@@ -20,8 +20,8 @@ interface UseSessionSignalRParams {
tenantId: string;
sessionTenantIdFromSession: string | undefined;
globalAdminMode: boolean;
sessionIdRef: React.MutableRefObject<string>;
sessionRef: React.MutableRefObject<Session | null>;
sessionIdRef: React.RefObject<string>;
sessionRef: React.RefObject<Session | null>;
resolveEffectiveTenantId: () => string | null;
signalR: SignalRApi;
scheduleFetchEvents: (delayMs?: number) => void;
@@ -29,8 +29,8 @@ export default function GlobalSearch() {
const mobileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mobileContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const abortRef = useRef<AbortController>();
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const abortRef = useRef<AbortController | undefined>(undefined);
const router = useRouter();
const { getAccessToken } = useAuth();
@@ -0,0 +1,21 @@
// Flat ESLint config (Next 16 removed `next lint`; run via `npm run lint`).
// Not wired into CI — kept runnable for local/spot use only.
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const config = [
{
ignores: [
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"node_modules/**",
"utils/page-lastmod.generated.ts",
],
},
...nextVitals,
...nextTs,
];
export default config;
@@ -31,6 +31,8 @@ export const msalConfig: Configuration = {
},
cache: {
cacheLocation: "sessionStorage", // Using sessionStorage for better XSS protection
// Deprecated in msal-browser v4 (console warning expected) but still functional;
// removed only in v5 — keep until the msal v5 / redirect-bridge migration.
storeAuthStateInCookie: true, // Required for Safari ITP compatibility during auth redirects
},
system: {
@@ -59,7 +61,7 @@ export const msalConfig: Configuration = {
logLevel: LogLevel.Info,
piiLoggingEnabled: false,
},
allowNativeBroker: false, // Disables WAM Broker
allowPlatformBroker: false, // Disables WAM Broker (renamed from allowNativeBroker in msal-browser v4)
},
};
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4 -2
View File
@@ -83,8 +83,10 @@ const nextConfig: NextConfig = {
: {}),
};
// Run `ANALYZE=1 npm run build` to emit interactive treemaps of the
// client/server bundles into .next/analyze/ for bundle-size investigation.
// Run `ANALYZE=1 npm run build -- --webpack` to emit interactive treemaps of
// the client/server bundles into .next/analyze/ for bundle-size investigation.
// (Next 16 builds with Turbopack by default; the analyzer is webpack-only and
// no-ops with a warning under Turbopack, so analyzer runs need --webpack.)
export default withBundleAnalyzer({ enabled: process.env.ANALYZE === "1" })(
nextConfig,
);
File diff suppressed because it is too large Load Diff
+10 -18
View File
@@ -11,12 +11,12 @@
"sync-error-codes": "node scripts/sync-error-codes.js",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@azure/msal-browser": "^3.28.0",
"@azure/msal-react": "^2.1.3",
"@azure/msal-browser": "^4.30.0",
"@azure/msal-react": "^3.0.29",
"@microsoft/applicationinsights-web": "^3.4.3",
"@microsoft/signalr": "^10.0.0",
"@types/leaflet": "^1.9.21",
@@ -24,9 +24,9 @@
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"next": "^15.5.22",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"next": "^16.2.12",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0",
"recharts": "^2.15.4"
},
@@ -34,11 +34,11 @@
"@next/bundle-analyzer": "^16.2.12",
"@types/dagre": "^0.7.54",
"@types/node": "^22.20.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^15.5.22",
"eslint-config-next": "^16.2.12",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
@@ -50,14 +50,6 @@
"overrides": {
"postcss": "^8.5.12",
"@nevware21/ts-utils": "^0.14.0",
"sharp": "^0.35.0",
"react-leaflet": {
"react": "$react",
"react-dom": "$react-dom"
},
"@react-leaflet/core": {
"react": "$react",
"react-dom": "$react-dom"
}
"sharp": "^0.35.0"
}
}
+3 -2
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -32,7 +32,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
@@ -3,12 +3,12 @@
* Run: node scripts/generate-lastmod.js
*/
export const PAGE_LASTMOD: Record<string, string> = {
"/": "2026-07-30T16:37:13+02:00",
"/about": "2026-07-30T18:48:00+02:00",
"/": "2026-07-30T22:21:18+02:00",
"/about": "2026-07-30T22:21:18+02:00",
"/roadmap": "2026-07-06T18:31:00+02:00",
"/changelog": "2026-07-06T18:12:31+02:00",
"/privacy": "2026-07-30T18:48:00+02:00",
"/terms": "2026-07-30T18:48:00+02:00",
"/privacy": "2026-07-30T22:21:18+02:00",
"/terms": "2026-07-30T22:21:18+02:00",
"/docs": "2026-07-06T18:12:31+02:00",
"/docs/private-preview": "2026-07-06T18:12:31+02:00",
"/docs/overview": "2026-07-06T18:12:31+02:00",