feat: migrate to React 19

Upgrade React 18.3.1 → 19.2.4 with full type compatibility.

Changes:
- react, react-dom → ^19.0.0
- @types/react, @types/react-dom → ^19.0.0
- lucide-react → ^0.577.0 (React 19 type fixes)
- @tiptap/* → ^3.20.4 (React 19 support)
- useRef calls now require explicit initial value (undefined)
- RefObject types updated for React 19 (includes null)
- MutableRefObject → RefObject (deprecated in 19)
- act() import moved from react-dom/test-utils to react
- Scoped JSX namespace imports added where needed
This commit is contained in:
iliya 2026-03-24 17:11:55 +02:00
parent e0d4782c80
commit 47dac2e8b5
28 changed files with 747 additions and 748 deletions

View file

@ -112,11 +112,11 @@
"@sentry/electron": "^7.10.0",
"@sentry/react": "^10.45.0",
"@tanstack/react-virtual": "^3.10.8",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/markdown": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/react": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/markdown": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
@ -135,13 +135,13 @@
"highlight.js": "^11.11.1",
"idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0",
"lucide-react": "^0.562.0",
"lucide-react": "^0.577.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
@ -170,8 +170,8 @@
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^25.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';

View file

@ -1,3 +1,4 @@
import type { JSX } from 'react';
/**
* Empty state for ChatHistory when no conversation exists.
*/

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { type JSX } from 'react';
import {
getHighlightProps,

View file

@ -1,3 +1,4 @@
import type { JSX } from 'react';
/**
* Loading skeleton for ChatHistory while conversation is loading.
* Industrial shimmer with organic line widths no generic pulse.

View file

@ -20,8 +20,8 @@ export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps)
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
const badgeRef = useRef<HTMLSpanElement>(null);
const enterTimer = useRef<ReturnType<typeof setTimeout>>();
const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
const enterTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const leaveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleMouseEnter = useCallback(() => {
if (!explanation) return;

View file

@ -37,7 +37,7 @@ export const AdvancedSection = ({
const checkForUpdates = useStore((s) => s.checkForUpdates);
// Auto-revert "not-available" / "error" status back to idle after a brief display
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>();
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (updateStatus === 'not-available' || updateStatus === 'error') {
revertTimerRef.current = setTimeout(() => {

View file

@ -63,8 +63,8 @@ export const ConfigEditorDialog = ({
}: ConfigEditorDialogProps): React.JSX.Element | null => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
const savedRevertTimerRef = useRef<ReturnType<typeof setTimeout>>();
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const savedRevertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [jsonError, setJsonError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { type JSX, useCallback, useEffect, useRef, useState } from 'react';
import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react';

View file

@ -1,4 +1,13 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
type JSX,
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';

View file

@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
import { type JSX, memo, useCallback, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';

View file

@ -113,7 +113,6 @@ export const MarkdownSplitView = React.memo(function MarkdownSplitView({
onMouseDown={handleMouseDown}
/>
)}
{/* Preview pane */}
<div className="flex-1 overflow-hidden bg-surface">
<MarkdownPreviewPane

View file

@ -50,7 +50,7 @@ export const SearchInFilesPanel = ({
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Monotonic request ID — prevents stale results from overwriting fresh ones
const requestIdRef = useRef(0);

View file

@ -150,7 +150,7 @@ export const ChangeReviewDialog = ({
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(new DOMRect());
const diffContentRef = useRef<HTMLDivElement>(null);
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>();
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const activeSelectionFileRef = useRef<string | null>(null);
// EditorView map for all visible file editors

View file

@ -225,7 +225,7 @@ export const CodeMirrorDiffView = ({
const onContentChangedRef = useRef(onContentChanged);
const onViewChangeRef = useRef(onViewChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
onAcceptRef.current = onHunkAccepted;
onRejectRef.current = onHunkRejected;

View file

@ -59,9 +59,9 @@ interface ContinuousScrollViewProps {
collapsedFiles?: Set<string>;
onToggleCollapse?: (filePath: string) => void;
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
isProgrammaticScroll: React.RefObject<boolean>;
isProgrammaticScroll: React.RefObject<boolean | null>;
teamName: string;
memberName: string | undefined;
fetchFileContent: (

View file

@ -1,4 +1,4 @@
import { Component, type ReactNode } from 'react';
import { Component, type JSX, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { type JSX, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';

View file

@ -17,7 +17,7 @@
* ```
*/
import { createContext, type ReactNode } from 'react';
import { createContext, type JSX, type ReactNode } from 'react';
// =============================================================================
// Context Definition

View file

@ -40,7 +40,7 @@ interface UseAutoScrollBottomOptions {
* ref instead of creating its own. Useful when the ref needs to be shared
* with other hooks (e.g., navigation coordinator).
*/
externalRef?: React.RefObject<HTMLDivElement>;
externalRef?: React.RefObject<HTMLDivElement | null>;
/**
* When this value changes, reset isAtBottom state to true.
@ -56,7 +56,7 @@ interface UseAutoScrollBottomReturn {
/**
* Ref to attach to the scroll container element.
*/
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/**
* Get whether the user is currently at the bottom of the scroll container.

View file

@ -8,7 +8,7 @@ interface UseContinuousScrollNavOptions {
interface UseContinuousScrollNavReturn {
scrollToFile: (filePath: string) => void;
isProgrammaticScroll: RefObject<boolean>;
isProgrammaticScroll: RefObject<boolean | null>;
}
export function useContinuousScrollNav(

View file

@ -47,8 +47,8 @@ export function useLazyFileContent(options: UseLazyFileContentOptions): UseLazyF
}, []);
// Refs for loadFile/processQueue to avoid circular useCallback deps
const loadFileRef = useRef<(fp: string) => Promise<void>>();
const processQueueRef = useRef<() => void>();
const loadFileRef = useRef<(fp: string) => Promise<void>>(undefined);
const processQueueRef = useRef<() => void>(undefined);
loadFileRef.current = async (filePath: string) => {
if (!shouldLoad(filePath)) return;

View file

@ -64,15 +64,15 @@ interface UseTabNavigationControllerOptions {
/** Tab ID for consuming navigation */
tabId: string;
/** Refs to AI group DOM elements */
aiGroupRefs: React.MutableRefObject<Map<string, HTMLElement>>;
aiGroupRefs: React.RefObject<Map<string, HTMLElement>>;
/** Refs to individual chat item DOM elements */
chatItemRefs: React.MutableRefObject<Map<string, HTMLElement>>;
chatItemRefs: React.RefObject<Map<string, HTMLElement>>;
/** Refs to individual tool item DOM elements */
toolItemRefs: React.MutableRefObject<Map<string, HTMLElement>>;
toolItemRefs: React.RefObject<Map<string, HTMLElement>>;
/** Function to expand an AI group (per-tab state) */
expandAIGroup: (groupId: string) => void;
/** Ref to scroll container */
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/** Height of sticky elements at top of scroll container */
stickyOffset?: number;
/** Optional helper to ensure a target group is mounted (e.g., virtualized lists) */

View file

@ -4,7 +4,7 @@ interface UseVisibleAIGroupOptions {
onVisibleChange: (aiGroupId: string) => void;
threshold?: number; // Default 0.5
/** Optional scroll container to observe against (important for nested scroll areas). */
rootRef?: RefObject<HTMLElement>;
rootRef?: RefObject<HTMLElement | null>;
}
interface UseVisibleAIGroupReturn {

View file

@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef } from 'react';
interface UseVisibleFileSectionOptions {
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: RefObject<HTMLElement | null>;
isProgrammaticScroll: RefObject<boolean>;
isProgrammaticScroll: RefObject<boolean | null>;
}
interface UseVisibleFileSectionReturn {
@ -18,7 +18,7 @@ export function useVisibleFileSection(
const visibleFilePaths = useRef<Set<string>>(new Set());
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
const observerRef = useRef<IntersectionObserver | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateTopmostVisible = useCallback(() => {
if (isProgrammaticScroll.current) return;

View file

@ -1,6 +1,5 @@
import React from 'react';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useVisibleAIGroup } from '../../../src/renderer/hooks/useVisibleAIGroup';
@ -23,8 +22,9 @@ describe('useVisibleAIGroup', () => {
});
it('uses provided rootRef as IntersectionObserver root', async () => {
const observerSpy = vi.fn((cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) =>
new FakeIntersectionObserver(cb, opts)
const observerSpy = vi.fn(
(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) =>
new FakeIntersectionObserver(cb, opts)
);
vi.stubGlobal('IntersectionObserver', observerSpy as unknown as typeof IntersectionObserver);