diff --git a/package-lock.json b/package-lock.json index d9ba22137..b6f941b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,6 +114,7 @@ "node_modules/@babel/core": { "version": "7.28.0", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -385,6 +386,7 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -422,6 +424,7 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1165,6 +1168,7 @@ "node_modules/@mui/icons-material": { "version": "7.2.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -1189,6 +1193,7 @@ "node_modules/@mui/material": { "version": "7.2.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6", "@mui/core-downloads-tracker": "^7.2.0", @@ -1293,6 +1298,7 @@ "node_modules/@mui/system": { "version": "7.2.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6", "@mui/private-theming": "^7.2.0", @@ -3177,6 +3183,7 @@ "node_modules/@types/react": { "version": "18.3.23", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3287,6 +3294,7 @@ "node_modules/@uppy/core": { "version": "4.4.7", "license": "MIT", + "peer": true, "dependencies": { "@transloadit/prettier-bytes": "^0.3.4", "@uppy/store-default": "^4.2.0", @@ -3301,6 +3309,7 @@ "node_modules/@uppy/dashboard": { "version": "4.3.4", "license": "MIT", + "peer": true, "dependencies": { "@transloadit/prettier-bytes": "^0.3.4", "@uppy/informer": "^4.2.1", @@ -3322,6 +3331,7 @@ "node_modules/@uppy/drag-drop": { "version": "4.1.3", "license": "MIT", + "peer": true, "dependencies": { "@uppy/utils": "^6.1.4", "preact": "^10.5.13" @@ -3344,6 +3354,7 @@ "node_modules/@uppy/progress-bar": { "version": "4.2.1", "license": "MIT", + "peer": true, "dependencies": { "@uppy/utils": "^6.1.1", "preact": "^10.5.13" @@ -3599,6 +3610,7 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3840,14 +3852,15 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -3926,6 +3939,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -4273,7 +4287,8 @@ }, "node_modules/csstype": { "version": "3.1.3", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -4391,6 +4406,7 @@ "node_modules/d3-selection": { "version": "3.0.0", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4981,6 +4997,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6557,6 +6574,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6568,6 +6586,7 @@ "version": "22.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "abab": "^2.0.6", "cssstyle": "^3.0.0", @@ -7009,6 +7028,7 @@ "node_modules/lucide-react": { "version": "0.545.0", "license": "ISC", + "peer": true, "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -7909,7 +7929,8 @@ }, "node_modules/monaco-editor": { "version": "0.44.0", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mri": { "version": "1.2.0", @@ -8509,6 +8530,7 @@ "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8528,8 +8550,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -8577,6 +8604,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8618,6 +8646,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8865,6 +8894,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9970,6 +10000,7 @@ "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/components/Collections/Optimizations/Optimizations.jsx b/src/components/Collections/Optimizations/Optimizations.jsx index 9192dbcef..b21cbfa4a 100644 --- a/src/components/Collections/Optimizations/Optimizations.jsx +++ b/src/components/Collections/Optimizations/Optimizations.jsx @@ -1,4 +1,4 @@ -import React, { memo, useState, useEffect, useCallback } from 'react'; +import React, { memo, useState, useEffect, useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; import { axiosInstance as axios } from '../../../common/axios'; import { Box } from '@mui/material'; @@ -6,33 +6,121 @@ import ProgressGrid from './ProgressGrid/ProgressGrid'; import Timeline from './Timeline/Timeline'; import OptimizationsTree from './Tree/OptimizationsTree'; +/** Poll interval while at least one optimization is running and the tab is visible (2–5s range). */ +const POLL_ACTIVE_MS = 4000; +/** Max delay between retries after a failed poll (exponential backoff cap). */ +const POLL_ERROR_RETRY_MAX_MS = 32000; + +function isRequestCanceled(error) { + return error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError'; +} + const Optimizations = ({ collectionName }) => { const [data, setData] = useState(null); const [selectedOptimization, setSelectedOptimization] = useState(null); const [requestTime, setRequestTime] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - const fetchData = useCallback(() => { - setIsRefreshing(true); - // Clear selected optimization when refreshing to show updated data - setSelectedOptimization(null); - axios - .get(`/collections/${encodeURIComponent(collectionName)}/optimizations?with=queued,completed,idle_segments`) - .then((response) => { - setData(response.data); + const abortRef = useRef(null); + const pollTimeoutRef = useRef(null); + const lastRunningRef = useRef(false); + const mountedRef = useRef(true); + const pollErrorBackoffMsRef = useRef(POLL_ACTIVE_MS); + + const runFetch = useCallback( + async ({ preserveSelection = false } = {}) => { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + + if (!preserveSelection) { + pollErrorBackoffMsRef.current = POLL_ACTIVE_MS; + setIsRefreshing(true); + setSelectedOptimization(null); + } + + const url = `/collections/${encodeURIComponent( + collectionName + )}/optimizations?with=queued,completed,idle_segments`; + + try { + const { data: next } = await axios.get(url, { signal: ac.signal }); + if (!mountedRef.current) return; + + setData(next); setRequestTime(Date.now()); - }) - .catch((error) => { + + const result = next?.result; + const hasRunning = Array.isArray(result?.running) && result.running.length > 0; + lastRunningRef.current = hasRunning; + pollErrorBackoffMsRef.current = POLL_ACTIVE_MS; + + clearTimeout(pollTimeoutRef.current); + if (hasRunning && !document.hidden) { + pollTimeoutRef.current = window.setTimeout(() => { + pollTimeoutRef.current = null; + void runFetch({ preserveSelection: true }); + }, POLL_ACTIVE_MS); + } + } catch (error) { + if (isRequestCanceled(error)) return; console.error('Error fetching optimizations:', error); - }) - .finally(() => { - setIsRefreshing(false); - }); - }, [collectionName]); + if (mountedRef.current && lastRunningRef.current && !document.hidden) { + const delay = pollErrorBackoffMsRef.current; + pollErrorBackoffMsRef.current = Math.min(pollErrorBackoffMsRef.current * 2, POLL_ERROR_RETRY_MAX_MS); + pollTimeoutRef.current = window.setTimeout(() => { + pollTimeoutRef.current = null; + void runFetch({ preserveSelection: true }); + }, delay); + } + } finally { + if (!preserveSelection && mountedRef.current) { + setIsRefreshing(false); + } + } + }, + [collectionName] + ); + + const fetchData = useCallback(() => { + void runFetch({ preserveSelection: false }); + }, [runFetch]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + abortRef.current?.abort(); + }; + }, []); + + useEffect(() => { + // this is used to stop polling when the user switches to another tab + const onVisibilityChange = () => { + if (document.hidden) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } else if (lastRunningRef.current) { + void runFetch({ preserveSelection: true }); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => document.removeEventListener('visibilitychange', onVisibilityChange); + }, [runFetch]); useEffect(() => { - fetchData(); - }, [fetchData]); + void runFetch({ preserveSelection: false }); + return () => { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + abortRef.current?.abort(); + }; + }, [collectionName, runFetch]); const handleOptimizationSelect = (optimization) => { if (optimization) { diff --git a/src/components/Collections/Optimizations/Optimizations.test.jsx b/src/components/Collections/Optimizations/Optimizations.test.jsx new file mode 100644 index 000000000..7426799c5 --- /dev/null +++ b/src/components/Collections/Optimizations/Optimizations.test.jsx @@ -0,0 +1,152 @@ +import { render, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Optimizations from './Optimizations'; + +// Stub child components so tests only exercise the polling logic. +vi.mock('./ProgressGrid/ProgressGrid', () => ({ default: () =>
})); +vi.mock('./Timeline/Timeline', () => ({ default: () => })); +vi.mock('./Tree/OptimizationsTree', () => ({ default: () => })); + +// Mock axios – we control what `.get` resolves to. +const getMock = vi.fn(); +vi.mock('../../../common/axios', () => ({ axiosInstance: { get: (...args) => getMock(...args) } })); + +/** Helper: build a response shaped like the real API. */ +const apiResponse = (running = []) => ({ + data: { result: { running, completed: [], queued: [] } }, +}); + +describe('Optimizations polling', () => { + const originalHiddenDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden'); + + beforeEach(() => { + vi.useFakeTimers(); + getMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + if (originalHiddenDescriptor) { + Object.defineProperty(document, 'hidden', originalHiddenDescriptor); + } else { + delete document.hidden; + } + }); + + it('polls while running is non-empty and stops when empty', async () => { + // First call: one running optimization → should schedule a poll. + getMock + .mockResolvedValueOnce(apiResponse([{ id: 1 }])) + // Second call: still running → another poll. + .mockResolvedValueOnce(apiResponse([{ id: 1 }])) + // Third call: nothing running → no more polls. + .mockResolvedValueOnce(apiResponse([])); + + await act(async () => { + render(