Skip to content

feat: ⬆️ Upgrade to React 19#8708

Open
joshistoast wants to merge 22 commits into
invoke-ai:mainfrom
joshistoast:feat/react-19
Open

feat: ⬆️ Upgrade to React 19#8708
joshistoast wants to merge 22 commits into
invoke-ai:mainfrom
joshistoast:feat/react-19

Conversation

@joshistoast
Copy link
Copy Markdown
Collaborator

@joshistoast joshistoast commented Dec 27, 2025

Summary

Upgrades react to version 19.2. We can take this opportunity to rewrite the codebase in a way where we can lean on the compiler to remove a lot of verbosity from the components (e.g. displayName, memo(), etc.).

Related Issues / Discussions

QA Instructions

Merge Plan

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

@github-actions github-actions Bot added frontend-deps PRs that change frontend dependencies frontend PRs that change frontend files labels Dec 27, 2025
@lstein lstein added the v6.13.x label Jan 30, 2026
@lstein
Copy link
Copy Markdown
Collaborator

lstein commented Jan 30, 2026

@joshistoast @blessedcoolant I’m trying to place this on the release roadmap. What is the level of work needed to test this thoroughly and bring it out of the draft stage. I’ve currently slated it for v6.13.x, with an eta in 4-6 weeks; does that sound right? Could go in sooner if feasible.

@blessedcoolant
Copy link
Copy Markdown
Collaborator

blessedcoolant commented Jan 31, 2026

@joshistoast @blessedcoolant I’m trying to place this on the release roadmap. What is the level of work needed to test this thoroughly and bring it out of the draft stage. I’ve currently slated it for v6.13.x, with an eta in 4-6 weeks; does that sound right? Could go in sooner if feasible.

It is a much larger endeavor. React 19 introduced the React Compiler that auto memoizes to improve performance. It is optional but if we are upgrading we might as well make use of it.

So that brings us to the fact that we use memoization manually almost across every single component in the app. We will have to refactor them all to permit the React Compiler to take over instead. This could introduce a ton of bugs that will need fixing. Not to mention, we will have to check the compatibility of our other dependencies and tools.

I'd rather this be one slowly and carefully. There's no urgent rush for us to upgrade just yet but we should do it long term. It makes our lives much easier. On the list. But let's keep it under "whenever it feels good".

@joshistoast
Copy link
Copy Markdown
Collaborator Author

I'm avoiding the text tool issues for now in order to not conflict with #9091

@joshistoast joshistoast added enhancement New feature or request and removed DO NOT MERGE labels May 10, 2026
@joshistoast
Copy link
Copy Markdown
Collaborator Author

I kinda like that react is now smart enough to refuse a build if we've made bad choices

@lstein lstein assigned dunkeroni and unassigned blessedcoolant May 11, 2026
@lstein lstein moved this from 6.14.x Theme: LIBRARY UPDATES to 6.13.5 LIBRARY UPDATES in Invoke - Community Roadmap May 12, 2026
@lstein lstein added 6.13.5 Library Updates and removed 6.14.x labels May 12, 2026
joshistoast and others added 10 commits May 13, 2026 00:07
don't think it's working rn but here it is
required also upgrading storybook and vitest
mostly updates refobject types to have null possibilities, and in some places made updates to mitigate some
immediate react 19 rendering errors
- no hooks get called in loops anymore
- effect starts async filter after render
- updates state only after awaiting fetches
- tracks cancellation
- use sets for workflow ids
this can cause cascading re-renders, and is counted as an error to the react 19 recommended linting.
# Conflicts:
#	invokeai/frontend/web/src/services/api/schema.ts
The React Compiler emits `useMemoCache` calls into every component,
which crashed two tests that invoked components as plain functions (via
direct call or `.type`) outside React's render path.

Rather than disable the compiler for tests, extract the testable surface
so the tests no longer need to render:

  - ImageMetadataActions: hoist the handler list to an exported
    `IMAGE_METADATA_ACTION_HANDLERS` array and render via map +
    type-guarded dispatch. Test asserts against the array.
  - AddBoardButton: extract the async create-and-dispatch flow into
    `createBoardAndDispatchActions`. Test exercises that function
    directly with vi.fn() doubles.
  - parsing.tsx: export `isCollectionMetadataHandler` and add
    `isUnrecallableMetadataHandler` to support the map-based render.
@joshistoast joshistoast marked this pull request as ready for review May 27, 2026 20:51
@joshistoast
Copy link
Copy Markdown
Collaborator Author

joshistoast commented May 28, 2026

The skipped checks are... failing?

EDIT: Ah, there was a higher priority job, rerunning...

@Pfannkuchensack
Copy link
Copy Markdown
Collaborator

Adversarial Review: PR #8708 (React 19 upgrade)

Findings

Critical: Text tool crash via Tooltip + Combobox in React 19

File: invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx:276

Symptom: Selecting the Text tool in the canvas toolbar throws TypeError: i.addEventListener is not a function, originating in the Chakra <Tooltip> component. Reproduces in both dev and production builds, so this is not a StrictMode side effect.

Root cause: The <Tooltip> in LineHeightSelect directly wraps a <Combobox> (from chakra-react-select). Chakra's Tooltip clones its child and forwards a ref, then calls addEventListener on ref.current to attach hover handlers. Under React 18 + chakra-react-select@4.9, the forwarded ref resolved to the wrapping DOM element. Under React 19 + chakra-react-select@4.10 (both bumped in this PR), the ref does not resolve to a DOM element, so addEventListener is undefined and the entire TextToolOptions subtree crashes the moment the Text tool mounts it.

This is a real React 19 compatibility gap in @invoke-ai/ui-library v0.0.48 (Chakra v2 + framer-motion 10 + chakra-react-select 4.10). The other tool buttons in the toolbar wrap Tooltip around IconButton, so they survive; only the Combobox-under-Tooltip path explodes.

Workaround applied: wrap the Combobox in a <Box> so Tooltip attaches to a real DOM div. Confirmed to fix the crash.

Other call sites of <Tooltip> + <Combobox>: six other files use the same combination but already have a <FormControl> or <Box> between Tooltip and Combobox, which should keep them safe. If any of them crash in the same way, the fix is identical (add an explicit <Box> wrapper):

  • invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel.tsx
  • invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx
  • invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageModel.tsx
  • invokeai/frontend/web/src/features/parameters/components/PostProcessing/ParamPostProcessingModel.tsx
  • invokeai/frontend/web/src/features/parameters/components/Upscale/ParamSpandrelModel.tsx
  • invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSpandrel.tsx

Proper fix: the workaround is symptom-only. The cleaner path is one of (a) ship a React 19 compatible Tooltip in @invoke-ai/ui-library, or (b) pin chakra-react-select to < 4.10 until (a) is done. Otherwise the same crash will reappear anywhere new code wraps a Combobox in a Tooltip.

Test: to expose this in CI, add a test that mounts LineHeightSelect (or any pure-Tooltip-around-Combobox component) and asserts mount does not throw.


High: Double Redux rehydration in dev mode

Files:

  • invokeai/frontend/web/src/main.tsx
  • invokeai/frontend/web/src/app/components/InvokeAIUI.tsx:24-38

Symptom: Every Redux slice logs Rehydrated slice "X" twice on app load. Confirmed visually.

Root cause: This PR moves <React.StrictMode> from inside InvokeAIUI to main.tsx, wrapping InvokeAIUI itself. In React 19 dev mode, StrictMode simulates an unmount/remount on initial mount, which causes the useEffect in InvokeAIUI to run twice: mount -> cleanup -> mount. Each mount invokes addStorageListeners() and triggers redux-remember rehydration. The store object itself is created once (it lives in useState's lazy initializer, which StrictMode does not double-invoke), but the side effects layered on top of it do run twice.

Severity: dev-only loop, so not a release blocker on its own. However, if addStorageListeners() is not perfectly idempotent the double registration carries into production as well (every storage event handled twice).

Test: to expose this, audit addStorageListeners for idempotency by mounting and remounting the hook and asserting only one storage listener is attached to window.


High: useFilteredWorkflows stuck isFiltering = true

File: invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useFilteredWorkflows.tsx:48-105

The PR introduces an isCancelled guard inside the filtering effect, and gates setIsFiltering(false) behind !isCancelled in the finally block. When workflows transitions from non-empty to empty during an in-flight filter:

  1. The cleanup for the in-flight effect sets isCancelled = true.
  2. The new effect short-circuits on workflows.length === 0 (lines 56-59) without touching isFiltering.
  3. The cancelled in-flight run's finally skips the false assignment.

Result: isFiltering is stuck at true forever, and any spinner gated on it spins indefinitely.

Test: to expose this, render the hook with two workflows, await a microtask so the async filter starts, immediately rerender with workflows = [], await the cancelled promise to settle, and assert isFiltering === false.


High: WorkflowLibraryModal renders empty list flash before sync completes

File: invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx:123

Before this PR, the hook returned didSync, a state value set inside the corrective effect, which deferred children render until the dispatch was committed. The new return value is !isLoadingRecentWorkflowsCount && !isLoadingYourWorkflowsCount, which flips to true on the render where the queries first transition to loaded - i.e. before the effect at lines 95-121 runs.

Consequence at lines 39-49 of the same file: {didSync && <WorkflowList />} mounts WorkflowList immediately with the stale, not-yet-corrected view (for example 'recent' when the user has zero recents). One tick later the effect dispatches workflowLibraryViewChanged and the list re-renders to the correct view. The user sees a visible empty-list flash on every modal open where a view switch is required.

Test: to expose this, render the hook with view = 'recent', recentWorkflowsCount = 0, yourWorkflowsCount > 0, both isLoading* false, and assert the hook does not report "ready" until after the corrective dispatch has occurred.


Medium: Loading-model indicator disappears for 5 seconds between model transitions

File: invokeai/frontend/web/src/features/controlLayers/hooks/useDeferredModelLoadingInvocationProgressMessage.ts:23

The new render gate delayedInvocationProgressMessage !== invocationProgressMessage requires the delayed-state to match the current raw progress message exactly. When the backend transitions from one "Loading model ..." string to another (e.g. main model -> refiner / VAE / ControlNet), the delayed-state is still the old string while the message is the new string, so the hook returns null for the next 5 seconds until the new timer fires.

The previous implementation stored a fixed localized string in delayedMessage, so the indicator stayed shown across model transitions.

Test: to expose this, drive $lastProgressMessage with "Loading model A" for 6 seconds, assert the hook returns the loading label, then set it to "Loading model B", advance 1 second, and assert the hook still returns the loading label.


Medium: CanvasTextOverlay starts at fallback size on first render

File: invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx:115, 338-339

measuredSize is now initialized to null instead of being seeded from contentMetrics and the configured font size. The first render computes effectiveWidth = fallbackWidth and effectiveHeight = fallbackHeight, derived from textContainerData rather than from the just-built text measurement. On a freshly opened text session the editor briefly appears at the wrong size until the post-mount ResizeObserver tick fires.

Test: extract the effectiveWidth / effectiveHeight selection into a pure helper and assert it returns the measured content size, not the fallback, on the first render after the text session opens.


Medium: navigation-api test coverage loss

File: invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts

Six "should return false when no active tab" tests (which exercised mockGetAppTab.mockReturnValue(null) with a connected app) were replaced with "should return false without app connection" tests that call navigationApi.disconnectFromApp(). Coverage of the "connected app but null active tab" branch is lost across focusPanelInActiveTab, toggleLeftPanel, toggleRightPanel, toggleLeftAndRightPanels, resetLeftAndRightPanels, and toggleViewerPanel. If any of those starts crashing or returning true when getAppTab() returns null while the app is connected, no test will fail.

Test: restore each removed test in its original form: keep the app connected, set mockGetAppTab.mockReturnValue(null), and assert each method returns false.


Low: SchedulerFieldInputComponent removed defensive optional chain

File: invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SchedulerFieldInputComponent.tsx:19, 37

field?.value was replaced with field.value (via const fieldValue = field.value). If a caller ever passes field === undefined, this now throws at render. Current callers always supply a defined field, but the safety net is gone.


Verified by manual testing

  • Text-tool crash confirmed in dev and prod. Workaround applied in TextToolOptions.tsx (Combobox wrapped in <Box>).
  • Double rehydration confirmed visible in the debug log on app load.
  • RgbaColorPicker hex input flagged in the original review but verified working in manual test - withdrawn.

Open questions

  • invokeai/frontend/web/vite.config.mts:27-29: resolve.tsconfigPaths: true replaces the removed vite-tsconfig-paths plugin. This is a rolldown-vite-only option. Confirmed only by the fact that the build succeeds; verify all app/, common/, features/ import aliases still resolve in both pnpm dev and pnpm build.
  • invokeai/frontend/web/src/features/stylePresets/components/StylePresetForm/StylePresetModal.tsx: the prior reset-on-mount effect was removed. Reset is now done via a Promise.resolve().then(...) microtask inside the main effect. If the modal is mounted while prefilledFormData is null and formData happens to be a leftover non-null value, the modal body could render with stale data for one frame before the microtask resets.
  • invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx and ParamNegativePrompt.tsx: the new ResizeObserver effect runs once with empty deps. If the underlying Textarea element is ever unmounted and remounted (e.g. layout change), the observer is not reattached and textareaWidth becomes frozen. Worth confirming the textarea element identity is stable for the component lifetime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.13.5 Library Updates enhancement New feature or request frontend PRs that change frontend files frontend-deps PRs that change frontend dependencies

Projects

Status: 6.13.5 LIBRARY UPDATES

Development

Successfully merging this pull request may close these issues.

5 participants