Compare commits

...
4 Commits
Author SHA1 Message Date
Josh Hawkins 15d3741d5d Verify motion search jobs belong to the requested camera 2026-08-04 18:08:55 -05:00
Josh Hawkins 013b5bd342 fix maximum update depth exceeded when dragging the timeline handlebar
Dragging the handlebar, especially quickly or with fast direction changes, could exceed React's nested update limit and unmount the whole app, leaving a blank screen. Motion search was worst affected.

The drag loop committed a new time into React state on every animation frame. Edge auto-scrolling mutates scrollTop each iteration, so the value always differed and React's same-value bail-out never engaged, letting the update chain run to the limit of 50. Pace those commits to one per 100ms and flush the pending value on release, so the drop position is still exact. The handlebar position and label are written to the DOM directly and remain at frame rate.

useUserInteraction dispatched state on every scroll and touchmove event; only commit on the leading edge.

Motion search also passed fresh array literals for the timeline's events, motion events and unavailable ranges, giving the segment memo and the drag effect new dependencies on every render. Both views also passed an inline arrow for onHandlebarDraggingChange, which is an effect dependency that calls setState.
2026-08-04 16:01:38 -05:00
Josh Hawkins 1f898ec15b margin tweak 2026-08-04 11:25:18 -05:00
Josh Hawkins c717efa67e preserve function names in production builds
adds only 162kb gzipped/450k unzipped to the bundle
2026-08-04 11:12:13 -05:00
8 changed files with 63 additions and 17 deletions
+2 -2
View File
@@ -182,7 +182,7 @@ async def get_motion_search_status_endpoint(
)
job = get_motion_search_job(job_id)
if not job:
if not job or job.camera != camera_name:
return JSONResponse(
content={"success": False, "message": "Job not found"},
status_code=404,
@@ -253,7 +253,7 @@ async def cancel_motion_search_endpoint(
)
job = get_motion_search_job(job_id)
if not job:
if not job or job.camera != camera_name:
return JSONResponse(
content={"success": False, "message": "Job not found"},
status_code=404,
@@ -73,7 +73,9 @@ export const VirtualizedEventSegments = forwardRef<
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
OVERSCAN_COUNT,
);
setVisibleRange({ start, end });
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end },
);
}
}, [segments.length, timelineRef]);
@@ -77,7 +77,9 @@ export const VirtualizedMotionSegments = forwardRef<
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
OVERSCAN_COUNT,
);
setVisibleRange({ start, end });
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end },
);
}
}, [segments.length, timelineRef]);
+26 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@@ -8,6 +8,8 @@ import { useTimeFormat } from "./use-date-utils";
import { useTranslation } from "react-i18next";
import useUserInteraction from "./use-user-interaction";
const DRAG_STATE_COMMIT_MS = 100;
type DraggableElementProps = {
contentRef: React.RefObject<HTMLElement | null>;
timelineRef: React.RefObject<HTMLDivElement | null>;
@@ -61,6 +63,8 @@ function useDraggableElement({
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
const lastDragTimeCommitRef = useRef(0);
const pendingDragTimeRef = useRef<number | null>(null);
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
@@ -154,9 +158,14 @@ function useDraggableElement({
if (isDragging) {
setIsDragging(false);
setInitialClickAdjustment(0);
if (pendingDragTimeRef.current !== null && setDraggableElementTime) {
setDraggableElementTime(pendingDragTimeRef.current);
pendingDragTimeRef.current = null;
}
}
},
[isDragging, setIsDragging],
[isDragging, setIsDragging, setDraggableElementTime],
);
const timestampToPixels = useCallback(
@@ -346,9 +355,21 @@ function useDraggableElement({
);
if (setDraggableElementTime) {
setDraggableElementTime(
targetSegmentTime + segmentDuration * (offset / segmentHeight),
);
const newTime =
targetSegmentTime + segmentDuration * (offset / segmentHeight);
const now = performance.now();
// don't commit on every animation frame, only commit it at a
// set interval to avoid React's nested update limit
if (now - lastDragTimeCommitRef.current >= DRAG_STATE_COMMIT_MS) {
lastDragTimeCommitRef.current = now;
pendingDragTimeRef.current = null;
setDraggableElementTime(newTime);
} else {
// Hold the newest value; handleMouseUp flushes it so the
// release still lands exactly where the handle was dropped.
pendingDragTimeRef.current = newTime;
}
}
if (draggingAtTopEdge || draggingAtBottomEdge) {
+7 -1
View File
@@ -8,6 +8,7 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) {
const [userInteracting, setUserInteracting] = useState(false);
const interactionTimeout = useRef<NodeJS.Timeout>(undefined);
const isProgrammaticScroll = useRef(false);
const userInteractingRef = useRef(false);
const setProgrammaticScroll = useCallback(() => {
isProgrammaticScroll.current = true;
@@ -16,13 +17,18 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) {
useEffect(() => {
const handleUserInteraction = () => {
if (!isProgrammaticScroll.current) {
setUserInteracting(true);
// Only commit state on the leading edge
if (!userInteractingRef.current) {
userInteractingRef.current = true;
setUserInteracting(true);
}
if (interactionTimeout.current) {
clearTimeout(interactionTimeout.current);
}
interactionTimeout.current = setTimeout(() => {
userInteractingRef.current = false;
setUserInteracting(false);
}, 3000);
} else {
@@ -51,7 +51,12 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review";
import {
MotionData,
REVIEW_PADDING,
ReviewSegment,
ZoomLevel,
} from "@/types/review";
import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
@@ -85,6 +90,7 @@ type MotionSearchViewProps = {
};
const DEFAULT_EXPORT_WINDOW_SECONDS = 60;
const NO_REVIEW_EVENTS: ReviewSegment[] = [];
export default function MotionSearchView({
config,
@@ -514,6 +520,12 @@ export default function MotionSearchView({
: null,
);
const timelineMotionEvents = useMemo(() => motionData ?? [], [motionData]);
const timelineNoRecordings = useMemo(
() => noRecordings ?? [],
[noRecordings],
);
const recordingParams = useMemo(
() => ({
before: currentTimeRange.before,
@@ -1054,11 +1066,11 @@ export default function MotionSearchView({
showHandlebar={true}
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={[]}
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
events={NO_REVIEW_EVENTS}
motion_events={timelineMotionEvents}
noRecordingRanges={timelineNoRecordings}
contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
onHandlebarDraggingChange={setScrubbing}
showExportHandles={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
@@ -1489,7 +1501,7 @@ export default function MotionSearchView({
isDesktop
? mainCameraAspect === "tall"
? "mr-2 h-full min-h-0 min-w-0 flex-1 items-center"
: "mr-2 h-full min-h-0 min-w-0 flex-1"
: "mx-2 h-full min-h-0 min-w-0 flex-1"
: mainCameraAspect === "tall"
? "flex-1 portrait:h-[40dvh] portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto portrait:items-center portrait:justify-center"
: "flex-1 portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto landscape:items-center landscape:justify-center",
+1 -1
View File
@@ -1199,7 +1199,7 @@ function Timeline({
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
onHandlebarDraggingChange={setScrubbing}
isZooming={isZooming}
zoomDirection={zoomDirection}
onZoomChange={handleZoomChange}
+3
View File
@@ -37,6 +37,9 @@ export default defineConfig({
},
},
},
esbuild: {
keepNames: true,
},
build: {
rollupOptions: {
input: {