Skip to content

feat(frontend): migrate to React 19 via Module Federation bridge#2525

Merged
malinskibeniamin merged 1 commit into
masterfrom
ben/console-react-19
Jun 24, 2026
Merged

feat(frontend): migrate to React 19 via Module Federation bridge#2525
malinskibeniamin merged 1 commit into
masterfrom
ben/console-react-19

Conversation

@malinskibeniamin

Copy link
Copy Markdown
Contributor

Draft for the UX team to review first.

What

Upgrade Console's frontend from React 18.3.1 → React 19.2.x while keeping Module Federation working with the Cloud UI host, which stays on React 18.

Why the obvious upgrade breaks MF

rp_console shares react/react-dom/@tanstack/*/react-hook-form/zod as singleton modules with Cloud UI. With a singleton, MF picks one React for the whole page — the React-18 host wins and feeds it to the remote. React-19-compiled remote code then runs against the React-18 runtime → "invalid hook call". You cannot run two React majors under singleton: true.

This is the same problem adp-ui solved in cloudv2#27303; admin-ui (cloudv2#27324) is the simpler standalone reference.

The fix: @module-federation/bridge-react@2.5.1

The bridge changes the host↔remote contract from "remote returns a React element rendered into the host tree" to "remote returns a { render, destroy } object that mounts into a host-supplied DOM node using its own React." That decouples the two React majors.

  • module-federation.config.ts: shared: {} (nothing shared) so Console bundles its own React 19, react-query, router, etc.
  • New ./BridgeApp expose (src/federation/console-federated-bridge.tsx) — createBridgeComponent from @module-federation/bridge-react/v19 (the default entry calls the legacy render API and throws on React 19). Wraps the unchanged ConsoleApp; same ConsoleAppProps contract.
  • ./App becomes a React-18-safe legacy shim (src/federation/console-legacy-app.tsx, ported from adp-ui). It returns a React-18 element shape whose ref mounts the real React 19 tree via Console's own createRootso the current Cloud UI host keeps rendering rp_console/App with no Cloud UI change. New hosts move to ./BridgeApp.
  • React Compiler target: '18' → '19'.

The non-obvious parts (Console-specific, not present in adp-ui)

adp-ui uses the vendored UI registry; Console uses the @redpanda-data/ui@4.2.0 package and a few legacy deps, which surfaced extra React-19 work:

  1. react-router-dom alias override. Installing @module-federation/bridge-react auto-activates a webpack plugin that aliases react-router-dom$ to its own router shim. Because Console declares no direct react-router-dom, the plugin falls back to its v6 shim (only exports BrowserRouter/RouterProvider) and breaks @redpanda-data/ui, which imports NavLink/Link from the real react-router-dom@7. Console doesn't federate routing (it uses @tanstack/react-router), so rsbuild.config.ts points react-router-dom$ back at the real package (the plugin spreads the user alias last, so it wins).
  2. react-beautiful-dnd@hello-pangea/dnd. rbd pulls react-redux@7, which calls ReactDOM.unstable_batchedUpdates (removed in React 19) at module init → DnD would crash at runtime. @hello-pangea/dnd is the maintained drop-in (identical API). 2 files.
  3. framer-motion@7motion/react. framer-motion@7's motion.* typings break under @types/react@19 (props resolve to unknown). Console already ships motion@12, so the 7 framer-motion imports move to motion/react.
  4. react-onclickoutside build shim. Statically imports findDOMNode (removed in React 19) via @redpanda-data/ui → react-datepicker; breaks ESM linking. Console renders no datepicker → identity-HOC shim (mirrors admin-ui).
  5. Global JSX namespace shim. @types/react@19 removed the global JSX namespace. @redpanda-data/ui bundles react-markdown@8, whose complex-types.ts ships as source (so skipLibCheck can't skip it) and uses keyof JSX.IntrinsicElements. A type-only ambient declaration restores JSX as an alias of React.JSX.
  6. Dedupe @types/react/memoize-one via overrides/resolutions (the dnd swap had re-hoisted an untyped memoize-one@4; a stray @types/react@18 lingered under two @types/* deps).
  7. App-code React-19 type fixes via types-react-codemod (scoped JSX, useRef, RefObject).

Verification (all Console frontend/ CI gates, run locally)

Gate Result
bun install --frozen-lockfile ✅ bun.lock + yarn.lock synced
bun run type:check (tsgo) ✅ 0 errors
bun run lint (ultracite) ✅ exit 0, idempotent (clean re-run)
bun run test:unit ✅ 832 passed (54 files)
bun run test:integration ✅ 1179 passed (111 files); zero new warnings (the one act() warning in catalog-tree.test.tsx pre-exists on master)
bun run build (rsbuild) ✅ manifest exposes ./App + ./BridgeApp, shared: []; no findDOMNode link error
bun run doctor (react-doctor) ✅ exit 0, detects React ^19.2.0
new federation tests ✅ 9 passed (config contract + legacy-shim lifecycle)

Cloud UI coordination (please read before merge)

  • rp_console/App keeps working unchanged via the legacy shim — the safety net, no hard cutover.
  • rp_console/BridgeApp is the new primary path. A coordinated cloudv2 / Cloud UI PR switches console-loader to consume it via createRemoteAppComponent (mirrors the adp-loader change). (Companion PR follows.)
  • ⚠️ rp_console/connect-tiles: the serverless onboarding wizard renders ConnectTiles directly in Cloud UI's React-18 tree and holds an imperative triggerSubmit() ref, which the element-shape shim can't carry. It is gated behind the default-off enable-serverless-onboarding-wizard flag, so this draft is safe to review without it; the fix is tracked as a scoped follow-up in the companion PR.

Known follow-ups

  • react-compiler-runtime is now unused with target: '19' (React 19 ships react/compiler-runtime) — left in place to keep this diff focused.
  • Pre-existing act() warning in catalog-tree.test.tsx (SQL studio) is unrelated to this change.

🤖 Generated with Claude Code

@malinskibeniamin

Copy link
Copy Markdown
Contributor Author

Cloud UI companion PR (consumes rp_console/BridgeApp): https://github.com/redpanda-data/cloudv2/pull/27360

@malinskibeniamin malinskibeniamin self-assigned this Jun 23, 2026
@malinskibeniamin malinskibeniamin added ui/ux lang/ts TypeScript/JavaScript performance Performance is one of the concerns dependencies labels Jun 23, 2026
@malinskibeniamin

Copy link
Copy Markdown
Contributor Author

@claude review

@malinskibeniamin malinskibeniamin marked this pull request as ready for review June 23, 2026 13:05
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @malinskibeniamin's task in 3m 40s —— View job


PR Review: React 19 migration via Module Federation bridge

  • Gather context (CLAUDE.md, diff, federation files)
  • Review MF config + rsbuild changes
  • Review bridge/shim/legacy-app + tests
  • Review shims + type fixes
  • Review dependency / lockfile changes

Reviewed the full diff. This is a careful, well-documented migration — the bridge approach is correct, the legacy shim is the right safety net, the dependency swaps (framer-motion→motion/react, react-beautiful-dnd→@hello-pangea/dnd) are complete in source (verified no stragglers), and the new federation tests cover the genuinely tricky part (ref churn + unmount lifecycle). Comments explaining each non-obvious shim are excellent.

Below are the things worth a second look before merge.

🔴 Other raw-component exposes have the same React-major hazard as connect-tiles

The PR correctly flags ./connect-tiles as unsafe (React-19 component rendered directly into the host's React-18 tree → invalid hook call). But the same hazard applies to ./EmbeddedApp (module-federation.config.ts:37), whose default export is a plain React component:

'./EmbeddedApp': './src/embedded-app.tsx',   // default export is a React-19 component

If any host still consumes rp_console/EmbeddedApp and renders <EmbeddedApp/> inside its own React-18 tree, it breaks exactly like connect-tiles. By contrast, ./injectApp is safeinject-app.tsx calls its own createRoot(...).render(<EmbeddedApp/>), so it self-roots with Console's React 19 and is decoupled from the host.

Question: Is ./EmbeddedApp still consumed as a bare component by any host? If yes it's a second landmine; if no, consider removing it (or routing it through the bridge / a self-rooting shim like ./App). Worth an explicit note in the "Cloud UI coordination" section either way.

🟡 react-router-dom$ alias hardcodes a hoisted path

rsbuild.config.ts:184:

'react-router-dom$': path.resolve(__dirname, 'node_modules/react-router-dom'),

This assumes react-router-dom (a transitive dep via @redpanda-data/ui) is hoisted to the top-level node_modules. It works today, but it's sensitive to hoisting layout — a future bun/yarn resolution change or a second consumer pinning a different range could de-hoist it and silently re-break NavLink/Link. require.resolve('react-router-dom') resolves the real install location regardless of hoisting and fails loudly if it's ever absent.

Fix this →

🟡 shared: {} duplicates the entire runtime in the embedded scene

With nothing shared, the embedded Console now ships its own React 19 plus @tanstack/react-query, @tanstack/react-router, react-hook-form, zod, etc., loaded alongside Cloud UI's own copies. That's inherent to the bridge (you can't singleton across React majors), and the performance label suggests this is understood — but it's worth confirming the loaded-together footprint (two react-query caches, two routers, two zod copies) is acceptable for the onboarding/embedded flows, not just that each bundle builds. A one-line note in the PR on the measured delta would help reviewers.

🟢 Legacy shim — looks correct, one durability note

console-legacy-app.tsx is the subtle part and it holds up:

  • The WeakMap<node, {root, version}> reuse means createRoot runs once per node (no "createRoot called twice" warning), and the version counter correctly collapses React's detach-then-reattach ref churn so a host re-render updates props in place instead of remount/unmount — exactly what the does not unmount during ref churn test asserts. 👍

One note: the hand-crafted element ($$typeof: Symbol.for('react.element'), _owner, _store) is valid for the React 18 element shape the host expects. That's the proven adp-ui pattern, but it's an undocumented coupling to the host's React major — worth a comment that this shape must be revisited if/when the Cloud UI host itself moves to React 19 (at which point ./BridgeApp should be the only path and ./App can be retired).

Minor / nits

  • react-compiler-runtime (package.json:122) is now dead weight with target: '19' (React 19 ships react/compiler-runtime). Already acknowledged as a follow-up — fine to defer, just don't lose track of it.
  • The app-code type fixes (scoped JSX, RefObject<T | null>, cloneElement generic in indeterminate-checkbox.tsx) are all standard types-react-codemod output and look correct.
  • The global JSX ambient shim (react-19-global-jsx.d.ts) is appropriately scoped (type-only alias of React.JSX) and well-justified by the react-markdown@8 source-shipped complex-types.ts. Good that it's marked for removal once upstreams catch up.

Couldn't independently verify

I reviewed source statically but did not re-run the CI gates locally (type:check, build, tests) in this environment — I'm trusting the green table in the PR body for those. The runtime correctness of the bridge against an actual React-18 Cloud UI host ultimately depends on the companion cloudv2#27360; the connect-tiles/EmbeddedApp direct-render paths are the ones I'd smoke-test manually before the flag flips.

Overall: solid, well-scoped, and safe to land as a draft. The main thing I'd resolve before the real cutover is the ./EmbeddedApp question above.
· ben/console-react-19

* by the Apache License, Version 2.0
*/

import { DragDropContext, Draggable, Droppable, type DropResult, type ResponderProvided } from '@hello-pangea/dnd';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react-beautiful-dnd is unmaintained and breaks under React 19: it pulls in react-redux@7, which calls ReactDOM.unstable_batchedUpdates at module init — that API was removed in React 19, so any drag-and-drop mount would throw at runtime. @hello-pangea/dnd is the maintained drop-in fork with an identical API (DragDropContext/Droppable/Draggable/DropResult/ResponderProvided) and React 19 support, so the only change is the import specifier (here and in preview-settings.tsx).

Comment thread frontend/package.json Outdated
Comment on lines +203 to +215
"overrides": {
"dompurify": "^3.4.0",
"prismjs": "^1.30.0",
"baseline-browser-mapping": "2.10.33"
"baseline-browser-mapping": "2.10.33",
"memoize-one": "^6.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.2"
},
"resolutions": {
"baseline-browser-mapping": "2.10.33"
"baseline-browser-mapping": "2.10.33",
"memoize-one": "^6.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.2"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if we need either of the overrides/resolutions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct — I tested it. Only memoize-one is actually needed: removing react-beautiful-dnd (which pinned memoize-one@^5) lets bun re-hoist the untyped memoize-one@4.1.0 from react-highlight-words, and src/config.ts imports it directly → TS7016 (no bundled types). Pinning memoize-one@^6 (typed; the version @hello-pangea/dnd and react-select already want) fixes it.

The @types/react / @types/react-dom overrides turned out not to be needed after the rebase — a single @types/react@19.2.17 now resolves on its own — so I dropped those. Kept memoize-one in both overrides and resolutions to match the existing baseline-browser-mapping pin (npm reads overrides, the yarn.lock side reads resolutions).

@malinskibeniamin malinskibeniamin requested a review from a team June 23, 2026 13:11
@malinskibeniamin malinskibeniamin marked this pull request as draft June 23, 2026 13:14
Upgrade Console's frontend from React 18.3.1 to React 19.2.x while keeping
Module Federation working with the React-18 Cloud UI host via
@module-federation/bridge-react.

- shared: {} so the bridge isolates Console's own React 19
- ./BridgeApp expose (createBridgeComponent from /v19); ./App becomes a
  React-18-safe legacy shim so the current Cloud UI keeps working with no
  host change
- react-beautiful-dnd -> @hello-pangea/dnd (react-redux 7's
  unstable_batchedUpdates is removed in React 19)
- framer-motion@7 -> motion/react (React 19 compatible)
- build shims: react-onclickoutside (findDOMNode) and a react-router-dom
  alias override to neutralize bridge-react's router hijack
- restore the global JSX namespace for @redpanda-data/ui's bundled
  react-markdown@8
- React 19 type fixes via types-react-codemod (scoped JSX, useRef, RefObject)
- pin memoize-one to a typed version (removing react-beautiful-dnd re-hoisted
  an untyped memoize-one@4)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@malinskibeniamin malinskibeniamin marked this pull request as ready for review June 23, 2026 13:53
@malinskibeniamin malinskibeniamin enabled auto-merge (squash) June 24, 2026 11:00
@malinskibeniamin malinskibeniamin merged commit 81b6c53 into master Jun 24, 2026
20 checks passed
@malinskibeniamin malinskibeniamin deleted the ben/console-react-19 branch June 24, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies lang/ts TypeScript/JavaScript performance Performance is one of the concerns ui/ux

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants