Skip to content

feat: drop React 18 support and migrate to React 19 idioms#796

Merged
chybisov merged 3 commits into
mainfrom
feat/react-19-full-migration
Jun 19, 2026
Merged

feat: drop React 18 support and migrate to React 19 idioms#796
chybisov merged 3 commits into
mainfrom
feat/react-19-full-migration

Conversation

@chybisov

@chybisov chybisov commented Jun 19, 2026

Copy link
Copy Markdown
Member

Which Linear task is linked to this PR?

EMB-456

Why was it implemented this way?

Formalizes the move off React 18 — the repo already ran on React 19.2.7, so this makes it official and adopts React 19 idioms:

  • react/react-dom peer deps narrowed >=18>=19 (widget, wallet-management, widget-light).
  • forwardRef removed from 11 components (ref passed as a prop; useImperativeHandle kept).
  • <Context.Provider><Context> and useContextuse() across packages.
  • Header ResizeObserver → React 19 ref-cleanup callback.
  • The 6 widget-provider-* packages use React-19-only APIs (use()), so they now declare a react >=19 peer dependency.

Bumped minor (changeset): v4 is already released, so the React-version floor is treated as a support-matrix change, not a breaking API change.

The Rust/oxc React Compiler was evaluated and deferred — it's a no-op in the rolldown bundler path at current versions (experimental); the Babel path was intentionally not used.

Also refreshes a few stale facts in CLAUDE.md (separate docs: commit).

Visual showcase (Screenshots or Videos)

No visual change. Verified: check:types + Biome across all packages, 40 widget tests, full build, and browser smoke of the default + drawer-ref paths.

Checklist before requesting a review

  • I have performed a self-review and testing of my code.
  • This pull request is focused and addresses a single problem.
  • If this PR modifies the Widget API or adds new features that require documentation, I have updated the documentation in the public-docs repository. (N/A — no Widget API change.)

chybisov added 2 commits June 19, 2026 11:18
- Narrow react/react-dom peer deps from >=18 to >=19 (@lifi/widget,
  @lifi/wallet-management, @lifi/widget-light)
- Remove forwardRef from 11 components (ref passed as a prop;
  useImperativeHandle retained)
- <Context.Provider> -> <Context> and useContext -> use() across
  widget, wallet-management, widget-provider-*, widget-embedded
- Header ResizeObserver -> React 19 ref-cleanup callback
- Declare react >=19 peer dependency on the 6 widget-provider* packages
- MUI v7 -> v9, add Tron ecosystem to overview + dependency graph
- Correct i18n locale count (17), note widget-light has no tests yet
- Replace obsolete 'beta pre-mode' release section with the current
  4.x stable line (pre-mode exited, latest on npm is 4.x)
@changeset-bot

changeset-bot Bot commented Jun 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 28635d6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@lifi/widget Minor
@lifi/wallet-management Minor
@lifi/widget-light Minor
@lifi/widget-provider Minor
@lifi/widget-provider-ethereum Minor
@lifi/widget-provider-solana Minor
@lifi/widget-provider-bitcoin Minor
@lifi/widget-provider-sui Minor
@lifi/widget-provider-tron Minor
nft-checkout Patch
tanstack-router-example Patch
vite-iframe Patch
vite-iframe-wagmi Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

✅ E2E Dev Smoke — passing

Check Result
Dev server start (pnpm dev) ✅ started
Smoke tests ✅ passed

4 passed · 0 failed · 0 skipped · 18s

View run

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

E2E Examples — all passed

All examples passed in the latest run.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

E2E Playground results

passed  158 passed

Details

stats  158 tests across 10 suites
duration  1 minute, 58 seconds
commit  28635d6

📥 Download full HTML report (open the run → Artifacts → playwright-report)

@lifi-qa-agent

lifi-qa-agent Bot commented Jun 19, 2026

Copy link
Copy Markdown

🔍 QA Review — EMB-456

🔗 Linear Ticket · Pull Request #796
⚠️ New commits pushed after last review — re-analysing post-review changes.

🔁 Re-Review — Resolution Status

# Severity Type Issue Status
1 🟠 Medium Code ExchangeRateBottomSheet.tsx — unsafe RefObject cast in handlers ✅ Fixed
2 🟢 Low Code TokenDetailsSheetContent.tsx — stale typeof ref !== 'function' guard ✅ Fixed

✅ Issue #1ExchangeRateBottomSheet.tsx [RESOLVED]

Original finding: handleContinue/handleCancel/handleClose cast ref as RefObject<ExchangeRateBottomSheetBase> and called .current?.close() directly — unsafe because Ref<T> includes RefCallback | null which have no .current. Additionally, handleContinue was not wrapped in useCallback, creating an inconsistent stability profile with its sibling handlers.

Fix applied (commit 28635d67): A stable close(value, bottomSheetClose) callback is extracted with useCallback(…, []). All three handlers now call this shared close() via their own useCallback wrappers, removing the unsafe cast entirely. The useImperativeHandle implementation also delegates to the same close — ensuring consistent behaviour across all call paths.

The developer correctly noted that the suggested alternative (bottomSheetRef.current?.close() inline) would have skipped resolverRef.current?.(value), leaving the awaited accept/cancel promise unresolved. The actual fix is superior: a single close() that handles both the resolver and the sheet close, called consistently everywhere.

Verified in current file: no ref as RefObject cast present; all three handlers are useCallback; useImperativeHandle delegates to close. ✅


✅ Issue #2TokenDetailsSheetContent.tsx [RESOLVED]

Original finding: The close-button handler if (ref && typeof ref !== 'function') { ref.current?.close() } was a stale forwardRef-era guard — carried over without re-evaluation. A legitimate callback ref caller would have their close() silently skipped.

Fix applied (commit 28635d67): The ref prop is removed from TokenDetailsSheetContent entirely. The component now accepts a plain onClose: () => void callback. The close button calls onClose() directly — no guards, no ref manipulation. The parent TokenDetailsSheet.tsx owns the bottomSheetRef and passes onClose={() => bottomSheetRef.current?.close()}, keeping the imperative sheet close in the correct owner.

Verified in current files: no ref prop on TokenDetailsSheetContent; TokenDetailsSheet passes onClose={() => bottomSheetRef.current?.close()} inline. ✅


🧠 What this ticket does

This PR drops React 18 as a supported peer dependency and fully migrates the widget monorepo to React 19 idioms across all 9 publishable packages:

  1. Narrows react/react-dom peer deps >=18>=19 in all publishable packages
  2. Replaces all forwardRef() wrappers with ref-as-prop (11 components)
  3. Replaces all <Context.Provider> with <Context> shortform across monorepo
  4. Replaces all useContext(X) with use(X) across hooks and store selectors
  5. Modernises Header.tsx to ref-cleanup callback (React 19 ResizeObserver pattern)
  6. Updates CLAUDE.md to reflect current state
  7. Adds changeset for all 9 publishable packages as minor

✅ Ticket Coverage — High

All migration axes (peer deps, forwardRef, context API) applied consistently across the full monorepo. CI: 157/158 Playwright passing (1 known-flaky theme test predating this PR), Dev Smoke and Examples all pass. Post-review fixes address all flagged issues with no regressions.


🔗 Downstream Impact

Blocks: None.

Downstream consumers: Integrators already on React 19 are unaffected. The changeset explicitly documents the React 18 drop — integrators on React 18 face a hard breaking change and must upgrade. The TypeScript API surface change (ForwardRefExoticComponent → functional component accepting ref?: Ref<T>) is source-compatible for all React 19 callers.


✅ Verdict: Pass

Both previously flagged items are fully resolved. The implementation is correct, type-safe, and consistent with React 19 idioms. The post-review fixes improve on the original suggestions where appropriate (resolver preservation in ExchangeRateBottomSheet, prop-level API cleanup in TokenDetailsSheetContent).


QA Agent — 2026-06-19 (re-review)

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Requesting changes on 2 items — each requires either a code fix or an explicit acceptance comment with justification before this review is considered complete.

# Severity Type Issue / File
1 🟠 Medium Code ExchangeRateBottomSheet.tsx — unsafe RefObject cast in handleContinue/handleCancel/handleClose bypasses React 19 ref type safety
2 🟢 Low Code TokenDetailsSheetContent.tsx — stale typeof ref !== 'function' guard is dead code in React 19

1. [Medium] ExchangeRateBottomSheet.tsx — unsafe RefObject cast in handlers

handleContinue, handleCancel, and handleClose all call (ref as RefObject<ExchangeRateBottomSheetBase>).current?.close(...). The ref prop is typed Ref<ExchangeRateBottomSheetBase> which is RefObject | RefCallback | null. Casting to RefObject without narrowing silently does nothing if a callback ref or null is passed — RefCallback has no .current property. In the same file, useImperativeHandle correctly accepts ref directly, but the handlers bypass it by calling into ref.current directly instead of using the already-in-scope bottomSheetRef.current.

Additionally, handleContinue is not wrapped in useCallback while handleCancel and handleClose are — this creates an inconsistent stability profile for the three sibling handlers.

Suggested fix: Replace all (ref as RefObject<ExchangeRateBottomSheetBase>).current?.close(...) calls in the three handlers with bottomSheetRef.current?.close(...). Wrap handleContinue in useCallback([onContinue]) to match the other handlers.

File: packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.tsx lines ~40–57.

2. [Low] TokenDetailsSheetContent.tsx — stale typeof ref !== 'function' guard

The close button handler:

if (ref && typeof ref !== 'function') {
  ref.current?.close()
}

This guard was required in the forwardRef era to defend against legacy callback refs. Now that ref is a plain prop typed Ref<TokenDetailsSheetBase> | undefined, a caller could legitimately pass a callback ref — and this branch silently skips the close() call rather than routing through the imperative handle. The guard was carried over from the pre-migration code without re-evaluation.

Suggested fix: Either (a) use a local inner ref and call localRef.current?.close() (consistent with other sheets), or (b) if direct RefObject access is intentional, narrow explicitly and add a comment explaining why function refs are excluded.

File: packages/widget/src/components/TokenList/TokenDetailsSheetContent.tsx close button handler (~line 47).

💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Requesting changes on 2 items — each requires either a code fix or an explicit acceptance comment with justification before this review is considered complete.

# Severity Type Issue / File
1 🟠 Medium Code ExchangeRateBottomSheet.tsx — unsafe RefObject cast in handlers bypasses React 19 ref type safety
2 🟢 Low Code TokenDetailsSheetContent.tsx — stale typeof ref !== 'function' guard is dead code in React 19

1. [Medium] ExchangeRateBottomSheet.tsx — unsafe RefObject cast in handlers

handleContinue, handleCancel, and handleClose all cast ref as RefObject<ExchangeRateBottomSheetBase> then call .current?.close(...). The ref prop is typed Ref<ExchangeRateBottomSheetBase> which is RefObject | RefCallback | null. The cast silently does nothing if a callback ref is passed (no .current on a RefCallback) and will throw if ref is undefined. The component already holds a bottomSheetRef via useRef — use that instead of calling into the outer ref directly. Additionally, handleContinue is not wrapped in useCallback while handleCancel and handleClose are — inconsistent stability profile for three sibling handlers.

Suggestion: Replace all (ref as RefObject<ExchangeRateBottomSheetBase>).current?.close(...) with bottomSheetRef.current?.close(...). Wrap handleContinue in useCallback([onContinue]).

2. [Low] TokenDetailsSheetContent.tsx — stale typeof ref !== 'function' guard

The close button handler contains if (ref && typeof ref !== 'function') { ref.current?.close() }. This guard was required in the forwardRef era to defend against legacy callback refs. Now that ref is a plain prop typed Ref<TokenDetailsSheetBase> | undefined, a caller legitimately passing a callback ref will have the close silently skipped. Use a local inner ref and call localRef.current?.close() instead (consistent with ExchangeRateBottomSheet and TokenValueBottomSheet), or document explicitly why function refs are excluded.

💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.

- ExchangeRateBottomSheet: extract a stable local close() used by both
  useImperativeHandle and the continue/cancel/close handlers; drop the
  unsafe (ref as RefObject) cast and make all three handlers useCallback.
  Preserves the resolver call (using bottomSheetRef.close() directly would
  have skipped resolving the awaited promise).
- TokenDetailsSheet: pass an onClose callback to TokenDetailsSheetContent
  instead of forwarding ref, removing the stale 'typeof ref !== function'
  guard that silently swallowed callback-ref callers.
@chybisov

Copy link
Copy Markdown
Member Author

Addressed both QA items in 28635d67:

#1 ExchangeRateBottomSheet.tsx — extracted a stable local close() now used by both useImperativeHandle and the continue/cancel/close handlers, dropping the (ref as RefObject) cast; all three handlers are now useCallback.

Note: I did not apply the literal suggestion of calling bottomSheetRef.current?.close() in the handlers — that path only closes the sheet and would skip resolverRef.current?.(value), leaving the awaited accept/cancel promise unresolved. The local close(value, bottomSheetClose) preserves that resolver behavior while removing the unsafe cast.

#2 TokenDetailsSheetContent.tsx — stopped forwarding the parent ref into the content child; it now takes a plain onClose callback (TokenDetailsSheet passes () => bottomSheetRef.current?.close()), removing the stale typeof ref !== 'function' guard entirely.

Verified: check:types, Biome, and 40 widget tests pass.

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ QA re-review pass — both previously flagged items resolved in commit 28635d6: unsafe RefObject cast removed from ExchangeRateBottomSheet (stable close() extracted), stale forwardRef guard eliminated from TokenDetailsSheetContent (prop-level API refactor). No new issues found.

@effie-ms effie-ms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! 🚀

@chybisov chybisov merged commit 80c1387 into main Jun 19, 2026
46 checks passed
@chybisov chybisov deleted the feat/react-19-full-migration branch June 19, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants