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(