From 1e05501b9d240b2e6248b57db164f965e80505a8 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:03:47 +0200 Subject: [PATCH] refactor(client): finish the React 19 migration (#1826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical, and it ends the migration visibly. 45 files imported the whole react-dom default export to reach one function. They now import createPortal by name, across 54 call sites; six files already did it this way. main.tsx keeps its react-dom/client import, which is a different module and already correct. forwardRef is gone from every component. In React 19 ref is an ordinary prop, so the wrapper only obscured the signature: NumericInput — a plain DOM ref, no imperative handle involved JourneyMap — useImperativeHandle stays, only the wrapping goes JourneyMapGL — same JourneyMapAuto — same Each takes `ref?: Ref` in its own Props interface now, so the ref is part of the declared surface instead of arriving through a generic parameter. The two journey bindings in glLazy shrink to one line each. They existed as forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an ordinary prop it rides through the spread like everything else, which makes all six bindings in that file identical again. The five React.FC annotations go. Three were `Record>` for provider icons in files that never import React at all — those only typechecked through the UMD global — and are now ComponentType. The two pages become function declarations. TranslationContext uses the 19 context shorthand, which leaves its React default import unused, so that goes too. Also drops two dead vi.mocks of react-dom/server. Their comment still said "used in createMarkerIcon", which stopped being true when marker icons moved to the in-house serializer — nothing under src/ imports react-dom/server any more except the parity test that exists to compare against it. --- client/src/components/Admin/AddonManager.tsx | 4 ++-- .../BackgroundTasks/BackgroundTasksWidget.tsx | 4 ++-- .../Budget/BudgetPanelMemberChips.tsx | 6 ++--- client/src/components/Collab/CollabChat.tsx | 4 ++-- .../Collab/CollabChatEmojiPicker.tsx | 4 ++-- .../Collab/CollabChatReactionBadge.tsx | 4 ++-- client/src/components/Collab/CollabNotes.tsx | 4 ++-- .../CollabNotesCategorySettingsModal.tsx | 4 ++-- .../Collab/CollabNotesFilePreviewPortal.tsx | 4 ++-- .../Collab/CollabNotesFormModal.tsx | 4 ++-- client/src/components/Collab/CollabPolls.tsx | 6 ++--- .../Files/FileManagerAssignModal.tsx | 4 ++-- .../Files/FileManagerAvatarChip.tsx | 4 ++-- .../Files/FileManagerMarkdownPreviewModal.tsx | 4 ++-- .../Files/FileManagerPdfPreviewModal.tsx | 4 ++-- client/src/components/Journey/JourneyMap.tsx | 10 ++++---- .../src/components/Journey/JourneyMapAuto.tsx | 7 +++--- .../src/components/Journey/JourneyMapGL.tsx | 10 ++++---- .../Layout/InAppNotificationBell.tsx | 4 ++-- client/src/components/Layout/Navbar.tsx | 4 ++-- client/src/components/Map/glLazy.tsx | 24 ++++++------------- .../Packing/PackingListPanelImportModal.tsx | 4 ++-- .../Packing/PackingShareControl.tsx | 4 ++-- .../Planner/AirTrailImportModal.tsx | 4 ++-- .../components/Planner/BookingImportModal.tsx | 4 ++-- .../src/components/Planner/DayDetailPanel.tsx | 4 ++-- .../Planner/DayPlanSidebarNoteModal.tsx | 4 ++-- .../DayPlanSidebarTimeConfirmModal.tsx | 4 ++-- .../DayPlanSidebarTransportDetailModal.tsx | 4 ++-- .../components/Planner/FileImportModal.tsx | 4 ++-- .../Planner/PlacesSidebarListImportModal.tsx | 4 ++-- .../Planner/PlacesSidebarMobileDayPicker.tsx | 4 ++-- .../components/Planner/ReservationsPanel.tsx | 6 ++--- client/src/components/Todo/TodoListPanel.tsx | 6 ++--- client/src/components/Vacay/VacayPersons.tsx | 6 ++--- .../components/Vacay/VacaySharedCalendars.tsx | 4 ++-- .../src/components/shared/ConfirmDialog.tsx | 4 ++-- client/src/components/shared/ContextMenu.tsx | 4 ++-- .../src/components/shared/CopyTripDialog.tsx | 4 ++-- .../shared/CustomDateTimePicker.tsx | 4 ++-- client/src/components/shared/CustomSelect.tsx | 4 ++-- .../components/shared/CustomTimePicker.tsx | 4 ++-- client/src/components/shared/Modal.tsx | 4 ++-- client/src/components/shared/NumericInput.tsx | 12 +++++----- client/src/components/shared/StarRating.tsx | 4 ++-- client/src/components/shared/Tooltip.tsx | 4 ++-- client/src/i18n/TranslationContext.tsx | 4 ++-- client/src/mobile/components/MSheet.tsx | 4 ++-- .../screens/admin/MAdminAddonManager.tsx | 6 ++--- .../mobile/screens/trip/tabs/MCollabChat.tsx | 4 ++-- .../screens/trip/tabs/MFileLightbox.tsx | 4 ++-- client/src/pages/ForgotPasswordPage.tsx | 2 +- client/src/pages/JourneyPublicPage.test.tsx | 4 ---- client/src/pages/ResetPasswordPage.tsx | 2 +- client/src/pages/SharedTripPage.test.tsx | 5 ---- client/src/pages/TripPlannerPage.tsx | 10 ++++---- client/src/pages/VacayPage.tsx | 6 ++--- 57 files changed, 135 insertions(+), 153 deletions(-) diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 99b9c84d8..c30e92fb9 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ComponentType } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' @@ -27,7 +27,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) { ) } -const PROVIDER_ICONS: Record> = { +const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, } diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index 062841c78..36823ba3f 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react' @@ -124,7 +124,7 @@ export default function BackgroundTasksWidget() { navigate(`/trips/${task.tripId}`) } - return ReactDOM.createPortal( + return createPortal(
diff --git a/client/src/components/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx index c042638c1..376464f12 100644 --- a/client/src/components/Budget/BudgetPanelMemberChips.tsx +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useState, useEffect, useRef, useCallback } from 'react' import { Pencil, Users, Check } from 'lucide-react' import type { BudgetItemMember } from '../../types' @@ -51,7 +51,7 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: : label?.[0]?.toUpperCase() }
- {hover && ReactDOM.createPortal( + {hover && createPortal(
0 ? : } )} - {showDropdown && ReactDOM.createPortal( + {showDropdown && createPortal(
setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />} {/* Reaction quick menu (right-click) */} - {reactMenu && ReactDOM.createPortal( + {reactMenu && createPortal( handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />, document.body )} diff --git a/client/src/components/Collab/CollabChatEmojiPicker.tsx b/client/src/components/Collab/CollabChatEmojiPicker.tsx index 3ee269667..ec214351c 100644 --- a/client/src/components/Collab/CollabChatEmojiPicker.tsx +++ b/client/src/components/Collab/CollabChatEmojiPicker.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { EMOJI_CATEGORIES } from './CollabChat.constants' import { TwemojiImg } from './CollabChatTwemojiImg' @@ -37,7 +37,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj return () => document.removeEventListener('mousedown', close) }, [onClose, anchorRef]) - return ReactDOM.createPortal( + return createPortal(
{reaction.count > 1 && {reaction.count}} - {hover && names && ReactDOM.createPortal( + {hover && names && createPortal(
openFile(rawUrl).catch(() => {}) - return ReactDOM.createPortal( + return createPortal(
{isImage ? ( /* Image lightbox — floating controls */ diff --git a/client/src/components/Collab/CollabNotesFormModal.tsx b/client/src/components/Collab/CollabNotesFormModal.tsx index 5b7f83287..e6b6cd2ac 100644 --- a/client/src/components/Collab/CollabNotesFormModal.tsx +++ b/client/src/components/Collab/CollabNotesFormModal.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useState, useRef } from 'react' import { Plus, X } from 'lucide-react' import { useCanDo } from '../../store/permissionsStore' @@ -67,7 +67,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor const canSubmit = title.trim() && !submitting - return ReactDOM.createPortal( + return createPortal(
e.stopPropagation()} onSubmit={handleSubmit}>
@@ -176,7 +176,7 @@ function VoterChip({ voter, offset }: VoterChipProps) { }}> {voter.avatar_url ? : (voter.username || '?')[0].toUpperCase()}
- {hover && ReactDOM.createPortal( + {hover && createPortal(
setAssignFileId(null)}>
- {hover && ReactDOM.createPortal( + {hover && createPortal(
{ cancelled = true } }, [previewFileUrl]) - return ReactDOM.createPortal( + return createPortal(
setPreviewFile(null)} diff --git a/client/src/components/Files/FileManagerPdfPreviewModal.tsx b/client/src/components/Files/FileManagerPdfPreviewModal.tsx index ce5bbe4d6..9c3c97f54 100644 --- a/client/src/components/Files/FileManagerPdfPreviewModal.tsx +++ b/client/src/components/Files/FileManagerPdfPreviewModal.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { ExternalLink, Download, X } from 'lucide-react' import { openFile as openFileUrl } from '../../utils/fileDownload' import type { FileManagerState } from './useFileManager' @@ -6,7 +6,7 @@ import { triggerDownload } from './FileManager.helpers' export function PdfPreviewModal(S: FileManagerState) { const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S - return ReactDOM.createPortal( + return createPortal(
setPreviewFile(null)} diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 1345de53d..5ac006cbd 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' +import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react' import L from 'leaflet' import { useSettingsStore } from '../../store/settingsStore' @@ -31,6 +31,7 @@ interface MapEntry { } interface Props { + ref?: Ref checkins: any[] entries: MapEntry[] trail?: { lat: number; lng: number }[] @@ -84,9 +85,8 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st const EMPTY_TRAIL: { lat: number; lng: number }[] = [] -const JourneyMap = forwardRef(function JourneyMap( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom }, - ref +function JourneyMap( + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props, ) { const stableTrail = trail || EMPTY_TRAIL const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url) @@ -314,6 +314,6 @@ const JourneyMap = forwardRef(function JourneyMap(
) -}) +} export default JourneyMap diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index ab9332e1f..48af9b958 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -1,4 +1,4 @@ -import { forwardRef, Suspense, useImperativeHandle, useRef } from 'react' +import { Suspense, useImperativeHandle, useRef, type Ref } from 'react' import { useSettingsStore } from '../../store/settingsStore' import JourneyMap, { type JourneyMapHandle } from './JourneyMap' import ErrorBoundary from '../shared/ErrorBoundary' @@ -22,6 +22,7 @@ interface MapEntry { } interface Props { + ref?: Ref checkins: unknown[] entries: MapEntry[] trail?: { lat: number; lng: number }[] @@ -33,7 +34,7 @@ interface Props { paddingBottom?: number } -const JourneyMapAuto = forwardRef(function JourneyMapAuto(props, ref) { +function JourneyMapAuto({ ref, ...props }: Props) { const provider = useSettingsStore(s => s.settings.map_provider) const token = useSettingsStore(s => s.settings.mapbox_access_token) const leafletRef = useRef(null) @@ -67,6 +68,6 @@ const JourneyMapAuto = forwardRef(function JourneyM } // eslint-disable-next-line @typescript-eslint/no-explicit-any return -}) +} export default JourneyMapAuto diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 7a1c7c043..5524094a1 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' +import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' @@ -23,6 +23,7 @@ interface MapEntry { } interface Props { + ref?: Ref checkins: unknown[] entries: MapEntry[] trail?: { lat: number; lng: number }[] @@ -209,9 +210,8 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H const EMPTY_TRAIL: { lat: number; lng: number }[] = [] -const JourneyMapGL = forwardRef(function JourneyMapGL( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl }, - ref +function JourneyMapGL( + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props, ) { const stableTrail = trail || EMPTY_TRAIL const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) @@ -502,6 +502,6 @@ const JourneyMapGL = forwardRef(function JourneyMapGL
) -}) +} export default JourneyMapGL diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx index 680cd64b1..ffabc2057 100644 --- a/client/src/components/Layout/InAppNotificationBell.tsx +++ b/client/src/components/Layout/InAppNotificationBell.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useNavigate } from 'react-router' import { Bell, Trash2, CheckCheck } from 'lucide-react' import { useTranslation } from '../../i18n' @@ -67,7 +67,7 @@ export default function InAppNotificationBell(): React.ReactElement { )} - {open && ReactDOM.createPortal( + {open && createPortal( <>
setOpen(false)} />
- {userMenuOpen && ReactDOM.createPortal( + {userMenuOpen && createPortal( <>
setUserMenuOpen(false)} />
diff --git a/client/src/components/Map/glLazy.tsx b/client/src/components/Map/glLazy.tsx index 870095475..7a5fd4e56 100644 --- a/client/src/components/Map/glLazy.tsx +++ b/client/src/components/Map/glLazy.tsx @@ -1,4 +1,3 @@ -import { forwardRef } from 'react' import { lazyWithRetry } from '../../utils/lazyWithRetry' /** @@ -39,15 +38,11 @@ export const JourneyMapGLMapbox = lazyWithRetry(async () => { import('./engines/mapbox'), ]) const JourneyMapGL = component.default - return { - // forwardRef because the journey map exposes an imperative handle - // (highlightMarker / focusMarker / invalidateSize) that has to survive the wrapper. - /* eslint-disable @typescript-eslint/no-explicit-any */ - default: forwardRef(function JourneyMapGLMapboxBinding(props: any, ref: any) { - return - }), - /* eslint-enable @typescript-eslint/no-explicit-any */ - } + // The journey map exposes an imperative handle (highlightMarker / focusMarker / + // invalidateSize). Since React 19 its ref is an ordinary prop, so it rides + // through the spread and the binding needs no forwardRef wrapper. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { default: (props: any) => } }) export const JourneyMapGLMaplibre = lazyWithRetry(async () => { @@ -56,13 +51,8 @@ export const JourneyMapGLMaplibre = lazyWithRetry(async () => { import('./engines/maplibre'), ]) const JourneyMapGL = component.default - return { - /* eslint-disable @typescript-eslint/no-explicit-any */ - default: forwardRef(function JourneyMapGLMaplibreBinding(props: any, ref: any) { - return - }), - /* eslint-enable @typescript-eslint/no-explicit-any */ - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { default: (props: any) => } }) export const GlMapPreviewMapbox = lazyWithRetry(async () => { diff --git a/client/src/components/Packing/PackingListPanelImportModal.tsx b/client/src/components/Packing/PackingListPanelImportModal.tsx index a7293a922..3b70871d3 100644 --- a/client/src/components/Packing/PackingListPanelImportModal.tsx +++ b/client/src/components/Packing/PackingListPanelImportModal.tsx @@ -1,10 +1,10 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { Upload } from 'lucide-react' import type { PackingState } from './usePackingListPanel' export function BulkImportModal(S: PackingState) { const { setShowImportModal, t, importText, setImportText, csvInputRef, handleCsvFile, handleBulkImport, parseImportLines } = S - return ReactDOM.createPortal( + return createPortal(
{ if (visibility === 'common') e.currentTarget.style.color = 'var(--text-faint)' }}> - {open && pos && ReactDOM.createPortal( + {open && pos && createPortal( <>
setOpen(false)} />
item.chain ? renderChain(item.chain) : renderFlight(item.flight!) - return ReactDOM.createPortal( + return createPortal(
{/* Hotel Picker Popup — portal to body to escape transform stacking context */} - {showHotelPicker && ReactDOM.createPortal( + {showHotelPicker && createPortal(
setShowHotelPicker(false)}>
e.stopPropagation()} style={{ diff --git a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx index 3e57b546a..d244ae8b1 100644 --- a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx +++ b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { NOTE_ICONS } from './DayPlanSidebar.constants' interface NoteModalUi { @@ -20,7 +20,7 @@ interface DayPlanSidebarNoteModalProps { export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) { return ( <> - {Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal( + {Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
{ setListImportOpen(false); setListImportUrl('') }} className="bg-[rgba(0,0,0,0.4)]" diff --git a/client/src/components/Planner/PlacesSidebarMobileDayPicker.tsx b/client/src/components/Planner/PlacesSidebarMobileDayPicker.tsx index 1f43fec11..d26d478b2 100644 --- a/client/src/components/Planner/PlacesSidebarMobileDayPicker.tsx +++ b/client/src/components/Planner/PlacesSidebarMobileDayPicker.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react' import type { SidebarState } from './usePlacesSidebar' @@ -7,7 +7,7 @@ export function MobileDayPickerSheet(S: SidebarState) { dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace, t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace, } = S - return ReactDOM.createPortal( + return createPortal(
{ setDayPickerPlace(null); setMobileShowDays(false) }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 1ce7e4f01..ca204f824 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' @@ -450,7 +450,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo )} {/* Delete confirmation */} - {showDeleteConfirm && ReactDOM.createPortal( + {showDeleteConfirm && createPortal(
)} - {confirmOpen && ReactDOM.createPortal( + {confirmOpen && createPortal(
{ e.stopPropagation(); setConfirmOpen(false) }}>
e.stopPropagation()}>
{t('reservations.confirm.deleteTitle')}
diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 146526e77..4c8254210 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -1,6 +1,6 @@ import { Fragment, useState, useMemo, useEffect, useRef } from 'react' import { avatarSrc } from '../../utils/avatarSrc' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' @@ -281,7 +281,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
)} - {isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal( + {isAddingNew && !selectedItem && !isMobile && createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} className="trek-modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}> @@ -299,7 +299,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
, document.body )} - {isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal( + {isAddingNew && !selectedItem && isMobile && createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} className="trek-modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}> diff --git a/client/src/components/Vacay/VacayPersons.tsx b/client/src/components/Vacay/VacayPersons.tsx index 5e511741a..43bb30e48 100644 --- a/client/src/components/Vacay/VacayPersons.tsx +++ b/client/src/components/Vacay/VacayPersons.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useState, useEffect } from 'react' import { UserPlus, Check, Loader2, Clock, X } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' @@ -124,7 +124,7 @@ export default function VacayPersons() {
{/* Invite Modal — Portal to body to avoid z-index issues */} - {showInvite && ReactDOM.createPortal( + {showInvite && createPortal(
setShowInvite(false)}>
{ setShowColorPicker(false); setColorEditUserId(null) }}>
setShowShare(false)}>
{/* Dropdown */} - {open && ReactDOM.createPortal( + {open && createPortal(
{ diff --git a/client/src/components/shared/CustomTimePicker.tsx b/client/src/components/shared/CustomTimePicker.tsx index 4b1aa08c5..f0c6841a0 100644 --- a/client/src/components/shared/CustomTimePicker.tsx +++ b/client/src/components/shared/CustomTimePicker.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { Clock, ChevronUp, ChevronDown } from 'lucide-react' import { useSettingsStore } from '../../store/settingsStore' import { formatClockTime, parseMeridiemTime } from '../../utils/formatters' @@ -148,7 +148,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
- {open && ReactDOM.createPortal( + {open && createPortal(
{ const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(), diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx index f167a277a..86f26f108 100644 --- a/client/src/components/shared/Modal.tsx +++ b/client/src/components/shared/Modal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useCallback, useRef } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { X } from 'lucide-react' const sizeClasses: Record = { @@ -49,7 +49,7 @@ export default function Modal({ if (!isOpen) return null - return ReactDOM.createPortal( + return createPortal(
, 'type' | 'onChange' | ' mode?: NumericMode /** Escape hatch for a field that must not steal the caret (none today). */ selectOnFocus?: boolean + ref?: Ref } /** @@ -52,10 +53,9 @@ type Props = Omit, 'type' | 'onChange' | ' * Styling and commit semantics stay with the caller: some fields save on every keystroke, * others on blur. This owns only the part that was uniformly broken. */ -export const NumericInput = forwardRef(function NumericInput( - { value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ...rest }, - ref, -) { +export function NumericInput({ + value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ref, ...rest +}: Props) { // Set while a deferred select() is queued; any input in that window cancels it. const selectPending = useRef(false) @@ -84,4 +84,4 @@ export const NumericInput = forwardRef(function Numeric }} /> ) -}) +} diff --git a/client/src/components/shared/StarRating.tsx b/client/src/components/shared/StarRating.tsx index 760a81562..d66e9b56b 100644 --- a/client/src/components/shared/StarRating.tsx +++ b/client/src/components/shared/StarRating.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { Star } from 'lucide-react' import { avatarSrc } from '../../utils/avatarSrc' import { useAuthStore } from '../../store/authStore' @@ -126,7 +126,7 @@ export default function PlaceRating({ ratings, ratingAvg, onRate, size = 16, com )} {/* Custom voter tooltip — fixed at the root so nothing clips it. */} - {tip && ratings.length > 0 && ReactDOM.createPortal( + {tip && ratings.length > 0 && createPortal(
{trigger} - {open && ReactDOM.createPortal( + {open && createPortal(
{children} + return {children} } export function useTranslation(): TranslationContextValue { diff --git a/client/src/mobile/components/MSheet.tsx b/client/src/mobile/components/MSheet.tsx index 0fc4a6b7f..5a39d2bd1 100644 --- a/client/src/mobile/components/MSheet.tsx +++ b/client/src/mobile/components/MSheet.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' export type MSheetVariant = 'card' | 'bottom' | 'drawer' export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque' @@ -190,7 +190,7 @@ export default function MSheet({ ? { transform: `translateY(${dragY}px)`, transition: 'none' } : { transition: 'transform 280ms var(--ease-drawer)' } - return ReactDOM.createPortal( + return createPortal( // m-root on the overlay so the --m-* tokens resolve even when the portal // has to fall back to document.body (sheet mounted in the same commit as // the shell). diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 62b889a0a..9aac2ab9a 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ComponentType } from 'react' import { adminApi } from '../../../api/client' import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' @@ -31,7 +31,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) { ) } -const PROVIDER_ICONS: Record> = { +const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, } @@ -320,7 +320,7 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) { interface MSubRowProps { icon?: typeof Luggage - providerIcon?: React.FC<{ size?: number }> + providerIcon?: ComponentType<{ size?: number }> title: string subtitle: string enabled: boolean diff --git a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx index 467218341..5841fdf74 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { ArrowUp, ChevronUp, Loader2, Reply, Trash2 } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import { collabApi } from '../../../../api/client' @@ -498,7 +498,7 @@ function MessageActionsPopover({ x, y, canDeleteOwn, t, onReact, onReply, onDele const height = POPOVER_HEIGHT + (canDeleteOwn ? POPOVER_DELETE_ROW : 0) const top = Math.max(64, Math.min(y - 96, window.innerHeight - height - POPOVER_MARGIN)) - return ReactDOM.createPortal( + return createPortal(
{ +function ForgotPasswordPage() { const { t } = useTranslation() // Page = wiring container: form state, the SMTP probe and submit live in the hook. const { navigate, email, setEmail, submitted, isLoading, smtpConfigured, handleSubmit } = useForgotPassword() diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx index 3c869f69f..62f3373c8 100644 --- a/client/src/pages/JourneyPublicPage.test.tsx +++ b/client/src/pages/JourneyPublicPage.test.tsx @@ -34,10 +34,6 @@ vi.mock('leaflet', () => { return { default: L, ...L }; }); -vi.mock('react-dom/server', () => ({ - renderToStaticMarkup: vi.fn(() => ''), -})); - // Mock JourneyMap since it uses vanilla Leaflet (L.map) which requires a real DOM vi.mock('../components/Journey/JourneyMap', () => ({ default: ({ entries }: any) =>
Map with {entries?.length || 0} entries
, diff --git a/client/src/pages/ResetPasswordPage.tsx b/client/src/pages/ResetPasswordPage.tsx index 81dbc3007..ddea33718 100644 --- a/client/src/pages/ResetPasswordPage.tsx +++ b/client/src/pages/ResetPasswordPage.tsx @@ -10,7 +10,7 @@ const inputBase: React.CSSProperties = { background: 'white', color: '#111827', } -const ResetPasswordPage: React.FC = () => { +function ResetPasswordPage() { const { t } = useTranslation() // Page = wiring container: token, form state, validation + submit live in the hook. const { diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 5de871b4b..10099aaa0 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -34,11 +34,6 @@ vi.mock('leaflet', () => { return { default: L, ...L }; }); -// Mock react-dom/server (used in createMarkerIcon) -vi.mock('react-dom/server', () => ({ - renderToStaticMarkup: vi.fn(() => ''), -})); - // Helper: render SharedTripPage under the correct route so useParams works function renderSharedTrip(token: string) { return render( diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 1fe164468..463b8005d 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1,5 +1,5 @@ import React, { useState, Suspense } from 'react' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useParams, useNavigate, useSearchParams } from 'react-router' import { useTripStore } from '../store/tripStore' import { useCanDo } from '../store/permissionsStore' @@ -396,7 +396,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {/* Mobile POI search controls live in a portal like the Plan/Places buttons so map touch handlers cannot swallow the tap targets. */} - {poiPillEnabled && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal( + {poiPillEnabled && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && createPortal(
, @@ -548,7 +548,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */} - {activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal( + {activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && createPortal(