diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx
index d5c8734..977140e 100644
--- a/src/components/app/App.jsx
+++ b/src/components/app/App.jsx
@@ -25,7 +25,7 @@ import Header from './Header';
import OIDLoginCallback from '../users/OIDLoginCallback';
import { OperationsContext } from './LayoutContext';
import Alert from '../common/Alert';
-import MapProject from '../map-projects/MapProject'
+import MapProjectRoute from '../map-projects/MapProjectRoute'
import MapProjects from '../map-projects/MapProjects'
import { useTranslation } from 'react-i18next';
@@ -166,8 +166,8 @@ const App = props => {
-
-
+
+
diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx
index 4a8cfd9..c7f53d7 100644
--- a/src/components/map-projects/MapProject.jsx
+++ b/src/components/map-projects/MapProject.jsx
@@ -94,6 +94,7 @@ import DataGridControls from './DataGridControls'
import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, getAutoMatchAIOnOpen, takeRunRows, clearRunRowStatuses, canRunAlgorithm, spendsMatchQuota, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows'
import { aiOnByDefault, getMaxRunRows, getMapperSettings } from '@openconceptlab/oclmap-extensions'
import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave'
+import { blockLeaving, continueLeaving, createRunClosers } from './leaveGuard'
import MatchSummaryCard from './MatchSummaryCard'
import MappingDecisionResult from './MappingDecisionResult'
import DecisionSelector from './DecisionSelector'
@@ -279,6 +280,13 @@ const MapProject = () => {
// A ref so every per-row backend call reads the run id synchronously without
// prop-drilling or re-renders; null outside a run → 'mapper-ui-manual'.
const automatchRunRef = React.useRef(null);
+ // Lets leaving close open run records at once.
+ const runClosersRef = React.useRef(null)
+ if(!runClosersRef.current)
+ runClosersRef.current = createRunClosers()
+ const isRunActiveRef = React.useRef(false)
+ const [leaveTarget, setLeaveTarget] = React.useState(null)
+ const ownNavigationRef = React.useRef(false)
const refreshRowStageSnapshotRef = React.useRef({});
const [previewLimit, setPreviewLimit] = React.useState(null) // {errorCode, limit, used} or null
@@ -315,6 +323,7 @@ const MapProject = () => {
const configSnapshotOnOpenRef = React.useRef(null)
const projectIdRef = React.useRef(null)
const onSaveRef = React.useRef(null)
+ const projectLogRef = React.useRef(() => {})
// A save can complete after the user has left the project
// (OpenConceptLab/ocl_issues#2829): these track the request still in flight
// (accurate after unmount, unlike isSaving state) and whether this page is
@@ -372,21 +381,37 @@ const MapProject = () => {
// captured when the change happened rather than what is on screen now.
React.useEffect(() => {
onSaveRef.current = onSave
+ projectLogRef.current = projectLog
})
// Leaving the project unmounts this page, often inside the autosave window:
// save the pending change now rather than drop it
// (OpenConceptLab/ocl_issues#2829). Nothing to save once signed out.
+ const saveNowOnLeave = () => saveOnLeave({
+ scheduler: autosaveSchedulerRef.current,
+ inFlightSave: inFlightSaveRef.current,
+ save: (reasons, {after}) => currentUserToken() && onSaveRef.current({source: 'auto', closeConfigure: false, reasons, leaving: true, after})
+ })
+
+ // Leaving stops a run at once, not gracefully: its record closes now and
+ // what it matched so far is saved. Once per run, whether or not leaving
+ // unmounts this page: a navigation to another project URL reuses it.
+ const stopRunOnLeave = () => {
+ if(!runClosersRef.current.hasOpen() && !(isRunActiveRef.current && !abortRef.current))
+ return
+ abortRef.current = true
+ projectLogRef.current({action: 'auto_match_stopped_on_leave'})
+ autosaveSchedulerRef.current.schedule('auto_match_stopped')
+ runClosersRef.current.closeAll(true)
+ }
+
React.useEffect(() => {
isMountedRef.current = true
installUnloadGuard()
return () => {
isMountedRef.current = false
- saveOnLeave({
- scheduler: autosaveSchedulerRef.current,
- inFlightSave: inFlightSaveRef.current,
- save: reasons => currentUserToken() && onSaveRef.current({source: 'auto', closeConfigure: false, reasons, leaving: true})
- })
+ stopRunOnLeave()
+ saveNowOnLeave()
}
}, [])
@@ -395,7 +420,7 @@ const MapProject = () => {
// covers a save that outlives this page.
React.useEffect(() => {
const warnIfUnsaved = event => {
- if(!autosaveSchedulerRef.current.hasPending() && !inFlightSaveRef.current)
+ if(!autosaveSchedulerRef.current.hasPending() && !inFlightSaveRef.current && !isRunActiveRef.current)
return
event.preventDefault()
event.returnValue = ''
@@ -404,6 +429,18 @@ const MapProject = () => {
return () => window.removeEventListener('beforeunload', warnIfUnsaved)
}, [])
+ // Closing the tab stops a run too, and closes its record.
+ React.useEffect(() => {
+ const stopRunOnPageHide = () => {
+ if(!runClosersRef.current.hasUnsettled())
+ return
+ abortRef.current = true
+ runClosersRef.current.closeAll(true, {keepalive: true})
+ }
+ window.addEventListener('pagehide', stopRunOnPageHide)
+ return () => window.removeEventListener('pagehide', stopRunOnPageHide)
+ }, [])
+
// repo state
const [repo, setRepo] = React.useState(false)
const [repoVersion, setRepoVersion] = React.useState(false)
@@ -1729,13 +1766,20 @@ const MapProject = () => {
const isUpdate = Boolean(project?.id)
if(!isAutoSave)
GAService.recordUpsertEvent('MapProject', isUpdate, 'map_project')
- let service = APIService.new().overrideURL(owner).appendToUrl('map-projects/')
- if(isUpdate)
- service = service.appendToUrl(project.id + '/')
- .put(formData, null, {"Content-Type": "multipart/form-data"}, undefined, true)
- else
- service = service.post(formData, null, {"Content-Type": "multipart/form-data"}, undefined, true)
+ const send = () => {
+ const service = APIService.new().overrideURL(owner).appendToUrl('map-projects/')
+ if(isUpdate)
+ return service.appendToUrl(project.id + '/')
+ .put(formData, null, {"Content-Type": "multipart/form-data"}, undefined, true)
+ return service.post(formData, null, {"Content-Type": "multipart/form-data"}, undefined, true)
+ }
+ // options.after: a save still in flight. This payload is built now and
+ // sent once that settles, so the older one can't land after it.
+ const service = options.after ? Promise.resolve(options.after).then(send, send) : send()
+ // Where the user was when saving: a save that lands once they went
+ // elsewhere mustn't pull them back.
+ const pathAtSave = history.location.pathname
const request = service.then(response => {
setIsSaving(false)
const status = response?.response?.status || response?.status
@@ -1767,9 +1811,14 @@ const MapProject = () => {
setProject(response.data)
if(!isUpdate)
refreshMapperQuotaCache()
- // A save that lands after the user left mustn't pull them back here.
- if(response.data.url && isMountedRef.current)
- history.push(response.data.url)
+ if(response.data.url && isMountedRef.current && !options.leaving && history.location.pathname === pathAtSave) {
+ ownNavigationRef.current = true
+ try {
+ history.push(response.data.url, {mapProjectFrom: pathAtSave})
+ } finally {
+ ownNavigationRef.current = false
+ }
+ }
if(!isAutoSave)
baseSetAlert({severity: 'success', message: t('map_project.successfully_saved'), duration: 2000})
@@ -1790,6 +1839,7 @@ const MapProject = () => {
inFlightSaveRef.current = request
if(isUpdate)
trackSave(project.id, request)
+ return request
}
// onSave takes options, so it can't be bound to onClick directly — the click
@@ -1830,7 +1880,12 @@ const MapProject = () => {
logsSendersRef.current.get(url).trigger({logs})
}
- const scheduleAutoSave = reason => autosaveSchedulerRef.current.schedule(reason)
+ // Not after unmount: frozen state plus a late run's refs would save an
+ // inconsistent project.
+ const scheduleAutoSave = reason => {
+ if(isMountedRef.current)
+ autosaveSchedulerRef.current.schedule(reason)
+ }
// ── Waiting out a busy server (ocl_issues#2849) ───────────────────────────
// The gate for a request's server, shared by every request this tab sends it.
@@ -2036,7 +2091,7 @@ const MapProject = () => {
// here, a newer run's record may be the current one (ocl_issues#2849).
// An AI-only run is recorded from aiOutcomes, its own AI results, not the
// shared row stages a manual action or a stopped run's late call can change.
- const completeAutomatchRun = async (selectedAlgos, intendedRows, run, aborted, aiOutcomes = {}) => {
+ const completeAutomatchRun = async (selectedAlgos, intendedRows, run, aborted, aiOutcomes = {}, {keepalive = false} = {}) => {
if(automatchRunRef.current === run)
automatchRunRef.current = null
if(!run?.id) return
@@ -2050,6 +2105,9 @@ const MapProject = () => {
aborted,
stoppedForQuota: Boolean(matchQuotaStopRef.current || (run.aiOnly && aiQuotaExhaustedRef.current)),
})
+ // The tab is closing: one request, sent before this function yields.
+ if(keepalive)
+ return APIService.new().overrideURL('/auto-match-runs/' + run.id + '/').keepalive('PATCH', payload)
// Closing the run is idempotent, so a busy server or a gateway error is
// retried; Stop doesn't end it, so a stopped run is closed too
// (ocl_issues#2849). requestWithCapacityRetry never rejects.
@@ -2579,6 +2637,7 @@ const MapProject = () => {
// began: it changed nothing to save.
const runStarted = !isRunStopped()
const aiOutcomes = {}
+ const closeRun = runClosersRef.current.add((aborted, options) => completeAutomatchRun(_selectedAlgos, rowsToProcess, runRecord, aborted, aiOutcomes, options))
try {
bulkMatchAlgoIdsRef.current = map(_selectedAlgos, 'id')
// Reset all algo stages to -1 for every row before starting so that
@@ -2698,7 +2757,7 @@ const MapProject = () => {
// way (ocl_issues#2849).
if(runStarted)
scheduleAutoSave(isRunStopped() ? 'auto_match_stopped' : 'auto_match')
- await completeAutomatchRun(_selectedAlgos, rowsToProcess, runRecord, isRunStopped(), aiOutcomes)
+ await closeRun(isRunStopped())
refreshMapperQuotaCache()
}
}, 1000)
@@ -2866,7 +2925,36 @@ const MapProject = () => {
const isRunningBulkAnalysis = bulkAIAnalysisEndedAt ? moment().isBetween(bulkAIAnalysisStartedAt, bulkAIAnalysisEndedAt) : Boolean(bulkAIAnalysisStartedAt)
const isRunningBulkBridgeCandidates = bridgeCandidatesEndedAt ? moment().isBetween(bridgeCandidatesStartedAt, bridgeCandidatesEndedAt) : Boolean(bridgeCandidatesStartedAt)
- const isRunningBulkScispacyCandidates = scispacyCandidatesStartedAt ? moment().isBetween(scispacyCandidatesStartedAt, scispacyCandidatesEndedAt) : Boolean(scispacyCandidatesStartedAt)
+ const isRunningBulkScispacyCandidates = scispacyCandidatesEndedAt ? moment().isBetween(scispacyCandidatesStartedAt, scispacyCandidatesEndedAt) : Boolean(scispacyCandidatesStartedAt)
+
+ // Leaving while a run is going asks first.
+ const isRunActive = Boolean(loadingMatches || isRunningBulkAnalysis || isRunningBulkBridgeCandidates || isRunningBulkScispacyCandidates)
+ isRunActiveRef.current = isRunActive
+ const unblockLeavingRef = React.useRef(null)
+ React.useEffect(() => {
+ if(!isRunActive)
+ return
+ const unblock = blockLeaving(history, {
+ isAllowed: () => ownNavigationRef.current,
+ onBlocked: setLeaveTarget,
+ })
+ unblockLeavingRef.current = unblock
+ return () => {
+ unblockLeavingRef.current = null
+ unblock()
+ }
+ }, [isRunActive])
+
+ const onStopRunAndLeave = () => {
+ const target = leaveTarget
+ setLeaveTarget(null)
+ stopRunOnLeave()
+ saveNowOnLeave()
+ unblockLeavingRef.current?.()
+ unblockLeavingRef.current = null
+ if(target)
+ continueLeaving(history, target)
+ }
// Only the latest request may set the version: a slower response is for a
// repository no longer chosen.
@@ -4414,7 +4502,10 @@ const MapProject = () => {
if(!isNumber(index)) return null
// A run's rerank ends when the run stops, and a stopped run proposes no
// more mappings; a row reranked by hand isn't stopped (ocl_issues#2849).
- const isStopped = isRunTraffic ? getRunStopCheck() : () => false
+ const runStop = isRunTraffic ? getRunStopCheck() : () => false
+ // Nothing is sent once the page is gone.
+ const isStopped = () => !isMountedRef.current || runStop()
+ if(!isMountedRef.current) return null
// The run this call belongs to, captured now: a newer run's stages are
// its own (ocl_issues#2849).
const runTicket = autoMatchRunTicketRef.current
@@ -4641,7 +4732,7 @@ const MapProject = () => {
|| (Array.isArray(def?.names) && def.names.some(n => n?.name))
)
const scheduleRerank = (rowIndex) => {
- if(!isNumber(rowIndex)) return
+ if(!isNumber(rowIndex) || !isMountedRef.current) return
const stage = rowStageRef.current[rowIndex] || {}
// ocl_online#105 Phase 5: this debounced path does most of the reranking
// during a bulk auto-match (candidates stream in asynchronously, so the
@@ -4689,7 +4780,7 @@ const MapProject = () => {
const scheduledTicket = autoMatchRunTicketRef.current
rerankDebounceRef.current[rowIndex] = setTimeout(() => {
delete rerankDebounceRef.current[rowIndex]
- if(isRunTraffic && scheduledTicket !== autoMatchRunTicketRef.current)
+ if(!isMountedRef.current || (isRunTraffic && scheduledTicket !== autoMatchRunTicketRef.current))
return
// isBulk=false (don't double the setAutoMatched side-effect the explicit
// bulk rerank already performs); isRunTraffic carries the run attribution.
@@ -5963,7 +6054,7 @@ const MapProject = () => {
baseDelayMs: 3000,
backoffFactor: 4,
jitterFactor: 0.25,
- isCancelled: () => abortRef.current,
+ isCancelled: () => abortRef.current || !isMountedRef.current,
isRetryable: isTransientNetworkError,
onAttemptFailed: (err, attempt) => {
const errorMessage = err?.response?.data?.detail || err?.message || t('unknown_error')
@@ -6788,6 +6879,22 @@ const MapProject = () => {
}
+
{
deleteProject && project?.id &&
setDeleteProject(false)} onDeleted={() => { autosaveSchedulerRef.current.cancel(); refreshMapperQuotaCache() }} project={project} />
diff --git a/src/components/map-projects/MapProjectRoute.jsx b/src/components/map-projects/MapProjectRoute.jsx
new file mode 100644
index 0000000..3a5e2ea
--- /dev/null
+++ b/src/components/map-projects/MapProjectRoute.jsx
@@ -0,0 +1,15 @@
+import React from 'react'
+import { useHistory, useLocation } from 'react-router-dom'
+import MapProject from './MapProject'
+import { getPageKey } from './leaveGuard'
+
+const MapProjectRoute = () => {
+ const location = useLocation()
+ const history = useHistory()
+ const pageRef = React.useRef({key: null, path: null})
+ if(pageRef.current.path !== location.pathname)
+ pageRef.current = getPageKey(pageRef.current, location, history.action)
+ return
+}
+
+export default MapProjectRoute
diff --git a/src/components/map-projects/__tests__/autosave.test.js b/src/components/map-projects/__tests__/autosave.test.js
index 181c180..dece609 100644
--- a/src/components/map-projects/__tests__/autosave.test.js
+++ b/src/components/map-projects/__tests__/autosave.test.js
@@ -233,33 +233,55 @@ test('saveOnLeave: nothing pending, nothing saved', () => {
assert.deepEqual(leaveSaves, [])
})
-test('saveOnLeave: waits for a save in flight, so the newer payload lands last', async () => {
+test('saveOnLeave: builds the payload at once, to send after the save in flight', () => {
const { scheduler } = setup()
- const order = []
- let finish
- const inFlightSave = new Promise(resolve => { finish = resolve }).then(() => order.push('in-flight save'))
+ const inFlightSave = new Promise(() => {})
+ const leaveSaves = []
scheduler.schedule('decision_change')
- saveOnLeave({ scheduler, inFlightSave, save: reasons => order.push(`leave save: ${reasons}`) })
- assert.deepEqual(order, [], 'must not save on top of the in-flight save')
+ saveOnLeave({ scheduler, inFlightSave, save: (reasons, {after}) => leaveSaves.push({reasons, after}) })
- finish()
- await inFlightSave
- await nextTick()
- assert.deepEqual(order, ['in-flight save', 'leave save: decision_change'])
+ // Called now, before anything after leaving can change what it saves.
+ assert.equal(leaveSaves.length, 1)
+ assert.deepEqual(leaveSaves[0].reasons, ['decision_change'])
+ assert.equal(leaveSaves[0].after, inFlightSave)
})
-test('saveOnLeave: still saves when the in-flight save fails', async () => {
+test('saveOnLeave: a rejected leave save is logged', async () => {
const { scheduler } = setup()
- const leaveSaves = []
- const inFlightSave = Promise.reject(new Error('network'))
+ const logged = []
+ const originalError = console.error
+ console.error = (...args) => logged.push(args[0])
+ try {
+ scheduler.schedule('decision_change')
+ saveOnLeave({ scheduler, inFlightSave: null, save: () => Promise.reject(new Error('network')) })
+ await nextTick()
+ } finally {
+ console.error = originalError
+ }
+ assert.deepEqual(logged, ['Mapper: saving on leave failed'])
+})
- scheduler.schedule('decision_change')
- saveOnLeave({ scheduler, inFlightSave, save: reasons => leaveSaves.push(reasons) })
+test('saveOnLeave: reopening the project waits for the leave save, not just the save before it', async () => {
+ const { scheduler } = setup()
+ const order = []
+ let finishFirst
+ let finishLeave
+ const first = trackSave('project-chain', new Promise(resolve => { finishFirst = resolve }).then(() => order.push('first save')))
+ const send = () => new Promise(resolve => { finishLeave = resolve }).then(() => order.push('leave save'))
- await inFlightSave.catch(() => {})
+ scheduler.schedule('auto_match_stopped')
+ // As onSave does with {after}: tracked now, sent once the first settles.
+ saveOnLeave({ scheduler, inFlightSave: first, save: (reasons, {after}) => trackSave('project-chain', Promise.resolve(after).then(send, send)) })
+ const reopened = whenSaved('project-chain').then(() => order.push('reopen'))
+
+ finishFirst()
+ await first
await nextTick()
- assert.deepEqual(leaveSaves, [['decision_change']])
+ assert.deepEqual(order, ['first save'], 'the reopen still waits')
+ finishLeave()
+ await reopened
+ assert.deepEqual(order, ['first save', 'leave save', 'reopen'])
})
// Saves that outlive their page: reopening the project waits for them, and
diff --git a/src/components/map-projects/__tests__/leaveGuard.test.js b/src/components/map-projects/__tests__/leaveGuard.test.js
new file mode 100644
index 0000000..f4c9916
--- /dev/null
+++ b/src/components/map-projects/__tests__/leaveGuard.test.js
@@ -0,0 +1,194 @@
+/**
+ * Tests for leaving a project while Auto Match runs.
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { blockLeaving, continueLeaving, createRunClosers, getPageKey } from '../leaveGuard.js'
+
+// Stands in for a history v4 object: block's prompt decides each transition.
+const createFakeHistory = () => {
+ let prompt = null
+ const calls = []
+ const transition = (action, location) => {
+ if(prompt && prompt(location, action) === false)
+ return
+ calls.push({action, location})
+ }
+ return {
+ calls,
+ block: fn => {
+ prompt = fn
+ return () => { prompt = null }
+ },
+ push: location => transition('PUSH', location),
+ replace: location => transition('REPLACE', location),
+ }
+}
+
+test('blockLeaving holds a navigation and hands it over', () => {
+ const history = createFakeHistory()
+ const blocked = []
+ blockLeaving(history, {onBlocked: next => blocked.push(next)})
+
+ history.push('/projects')
+
+ assert.deepEqual(history.calls, [])
+ assert.deepEqual(blocked, [{location: '/projects', action: 'PUSH'}])
+})
+
+test('blockLeaving lets an allowed navigation through', () => {
+ const history = createFakeHistory()
+ const blocked = []
+ blockLeaving(history, {isAllowed: location => location === '/project/1', onBlocked: next => blocked.push(next)})
+
+ history.push('/project/1')
+
+ assert.deepEqual(history.calls, [{action: 'PUSH', location: '/project/1'}])
+ assert.deepEqual(blocked, [])
+})
+
+test('continueLeaving replays a held navigation once unblocked', () => {
+ const history = createFakeHistory()
+ let held = null
+ const unblock = blockLeaving(history, {onBlocked: next => { held = next }})
+
+ history.replace({pathname: '/elsewhere'})
+ unblock()
+ continueLeaving(history, held)
+
+ assert.deepEqual(history.calls, [{action: 'REPLACE', location: {pathname: '/elsewhere', state: undefined}}])
+})
+
+test('continueLeaving pushes a held Back', () => {
+ const history = createFakeHistory()
+ continueLeaving(history, {location: {pathname: '/back'}, action: 'POP'})
+ assert.deepEqual(history.calls, [{action: 'PUSH', location: {pathname: '/back', state: undefined}}])
+})
+
+test('a run closer closes its run once, whoever calls first', async () => {
+ const closers = createRunClosers()
+ const closed = []
+ const close = closers.add(aborted => { closed.push(aborted) })
+
+ assert.equal(closers.hasOpen(), true)
+ // Leaving closes it at once; the run's own close, once its requests settle,
+ // must not close it again with a later status.
+ await closers.closeAll(true)
+ await close(false)
+
+ assert.deepEqual(closed, [true])
+ assert.equal(closers.hasOpen(), false)
+})
+
+test('a run that closed itself is not closed again on leave', async () => {
+ const closers = createRunClosers()
+ const closed = []
+ const close = closers.add(aborted => { closed.push(aborted) })
+
+ await close(false)
+ await closers.closeAll(true)
+
+ assert.deepEqual(closed, [false])
+})
+
+test('closeAll closes every run still open', async () => {
+ const closers = createRunClosers()
+ const closed = []
+ closers.add(aborted => { closed.push(['first', aborted]) })
+ closers.add(aborted => { closed.push(['second', aborted]) })
+
+ await closers.closeAll(true)
+
+ assert.deepEqual(closed, [['first', true], ['second', true]])
+})
+
+test('closing the tab resends a close still in flight, with its own status', async () => {
+ const closers = createRunClosers()
+ const calls = []
+ let settle
+ const close = closers.add((aborted, options) => {
+ calls.push([aborted, Boolean(options?.keepalive)])
+ if(calls.length === 1)
+ return new Promise(resolve => { settle = resolve })
+ })
+
+ const own = close(false)
+ assert.equal(closers.hasOpen(), false)
+ assert.equal(closers.hasUnsettled(), true)
+ await closers.closeAll(true, {keepalive: true})
+ settle()
+ await own
+
+ assert.deepEqual(calls, [[false, false], [false, true]])
+ assert.equal(closers.hasUnsettled(), false)
+})
+
+test('closing the tab sends nothing for a run already closed', async () => {
+ const closers = createRunClosers()
+ const calls = []
+ const close = closers.add((aborted, options) => { calls.push([aborted, Boolean(options?.keepalive)]) })
+
+ await close(false)
+ await closers.closeAll(true, {keepalive: true})
+
+ assert.deepEqual(calls, [[false, false]])
+})
+
+const start = {key: null, path: null}
+
+test('getPageKey gives another project a new page', () => {
+ const first = getPageKey(start, {pathname: '/p/1'}, 'PUSH')
+ const next = getPageKey(first, {pathname: '/p/2'}, 'PUSH')
+ assert.equal(first.path, '/p/1')
+ assert.deepEqual(next, {key: first.key + 1, path: '/p/2'})
+})
+
+test("getPageKey keeps the page for a save's own navigation", () => {
+ const page = getPageKey(start, {pathname: '/map-projects/new'}, 'POP')
+ const saved = getPageKey(page, {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, 'PUSH')
+ assert.deepEqual(saved, {key: page.key, path: '/p/1'})
+})
+
+test('getPageKey gives a new page on Back and Forward after new -> saved', () => {
+ const page = getPageKey(start, {pathname: '/map-projects/new'}, 'POP')
+ const saved = getPageKey(page, {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, 'PUSH')
+ const back = getPageKey(saved, {pathname: '/map-projects/new'}, 'POP')
+ // Forward replays the save's state, but it isn't the save.
+ const forward = getPageKey(back, {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, 'POP')
+
+ assert.notEqual(back.key, saved.key)
+ assert.notEqual(forward.key, back.key)
+ assert.deepEqual(forward, {key: back.key + 1, path: '/p/1'})
+})
+
+test('getPageKey gives a new page on Back to an entry a save pushed', () => {
+ const page = getPageKey(start, {pathname: '/map-projects/new'}, 'POP')
+ const saved = getPageKey(page, {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, 'PUSH')
+ const other = getPageKey(saved, {pathname: '/p/2'}, 'PUSH')
+ const back = getPageKey(other, {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, 'POP')
+ assert.deepEqual(back, {key: other.key + 1, path: '/p/1'})
+})
+
+test('continueLeaving drops the save marker, so a held Forward to a saved entry gets a new page', () => {
+ // new saved as /p/1 (kept), Back to new, Forward to /p/1 held by the dialog
+ // and continued as a push.
+ const history = createFakeHistory()
+ const page = getPageKey(start, {pathname: '/map-projects/new'}, 'POP')
+ const saved = getPageKey(page, {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, 'PUSH')
+ const back = getPageKey(saved, {pathname: '/map-projects/new'}, 'POP')
+
+ continueLeaving(history, {location: {pathname: '/p/1', state: {mapProjectFrom: '/map-projects/new'}}, action: 'POP'})
+ const [pushed] = history.calls
+ const forward = getPageKey(back, pushed.location, pushed.action)
+
+ assert.equal(pushed.location.state, undefined)
+ assert.deepEqual(forward, {key: back.key + 1, path: '/p/1'})
+})
+
+test('continueLeaving keeps other state', () => {
+ const history = createFakeHistory()
+ continueLeaving(history, {location: {pathname: '/x', state: {mapProjectFrom: '/y', other: 1}}, action: 'PUSH'})
+ assert.deepEqual(history.calls[0].location.state, {other: 1})
+})
diff --git a/src/components/map-projects/autosave.js b/src/components/map-projects/autosave.js
index 1b32b6e..a1b62b8 100644
--- a/src/components/map-projects/autosave.js
+++ b/src/components/map-projects/autosave.js
@@ -83,12 +83,12 @@ export const createAutosaveScheduler = ({
* Leaving the project unmounts MapProject, often inside the autosave window,
* and dropping the pending autosave there lost the last change — a whole Auto
* Match run if the user left within 5s of it finishing
- * (OpenConceptLab/ocl_issues#2829). This saves it now instead. A save still in
- * flight goes first, so the older payload can't land after the newer one.
+ * (OpenConceptLab/ocl_issues#2829). This saves it now instead.
*
- * The save always starts asynchronously: an error while building the payload
- * must not surface inside React's unmount and take down the page the user is
- * going to.
+ * save(reasons, {after}) builds the payload now, so nothing that changes once
+ * the page is gone gets into it, and sends it once after, the save still in
+ * flight, settles, so the older payload can't land after the newer one. An
+ * error building it is logged, never thrown into React's unmount.
*
* Returns whether a save was queued.
*/
@@ -96,10 +96,12 @@ export const saveOnLeave = ({ scheduler, inFlightSave, save }) => {
const reasons = scheduler.takePending()
if(!reasons)
return false
- const run = () => save(reasons)
- Promise.resolve(inFlightSave)
- .then(run, run)
- .catch(error => console.error('Mapper: saving on leave failed', error))
+ const fail = error => console.error('Mapper: saving on leave failed', error)
+ try {
+ Promise.resolve(save(reasons, {after: inFlightSave})).catch(fail)
+ } catch(error) {
+ fail(error)
+ }
return true
}
diff --git a/src/components/map-projects/leaveGuard.js b/src/components/map-projects/leaveGuard.js
new file mode 100644
index 0000000..8f0f3d7
--- /dev/null
+++ b/src/components/map-projects/leaveGuard.js
@@ -0,0 +1,59 @@
+// Leaving a project while Auto Match runs.
+
+// Holds in-app navigation, unless isAllowed, and hands it to onBlocked as
+// {location, action}. Returns the unblock function.
+export const blockLeaving = (history, {isAllowed = () => false, onBlocked}) =>
+ history.block((location, action) => {
+ if(isAllowed(location, action))
+ return undefined
+ onBlocked({location, action})
+ return false
+ })
+
+// A held Back/Forward (POP) can't be replayed by distance, so it is pushed,
+// without its mapProjectFrom: the push is the user's, not a save's.
+export const continueLeaving = (history, {location, action}) => {
+ const { mapProjectFrom, ...state } = location.state || {} // eslint-disable-line no-unused-vars
+ const next = {...location, state: Object.keys(state).length ? state : undefined}
+ return action === 'REPLACE' ? history.replace(next) : history.push(next)
+}
+
+// Each closer closes its run's record once, whether the run ends or the page
+// closes it first on leave. A close still in flight as the tab closes is sent
+// again with keepalive, as it was, since the tab's closing cancels it.
+export const createRunClosers = () => {
+ const unsettled = new Set()
+ const add = close => {
+ let result = null
+ let closedAs
+ const closeOnce = (aborted, options = {}) => {
+ if(result && (!unsettled.has(closeOnce) || !options.keepalive))
+ return result
+ if(!result)
+ closedAs = aborted
+ result = Promise.resolve(close(closedAs, options)).finally(() => unsettled.delete(closeOnce))
+ return result
+ }
+ closeOnce.isClosing = () => Boolean(result)
+ unsettled.add(closeOnce)
+ return closeOnce
+ }
+ return {
+ add,
+ closeAll: (aborted, options) => Promise.all([...unsettled].map(close => close(aborted, options))),
+ // A run whose record nothing has started closing yet.
+ hasOpen: () => [...unsettled].some(close => !close.isClosing()),
+ hasUnsettled: () => unsettled.size > 0,
+ }
+}
+
+// MapProject loads its project once, on mount, so another project's URL needs
+// a new page. Only a save's own navigation keeps it: a push or replace that
+// passes the path it left in location.state.mapProjectFrom. Back/Forward
+// replays that state, so a POP always gets a new page. Returns the key and
+// path to keep for the next location.
+export const getPageKey = ({key, path}, location, action) => {
+ if(key && action !== 'POP' && location.state?.mapProjectFrom === path)
+ return {key, path: location.pathname}
+ return {key: (key || 0) + 1, path: location.pathname}
+}
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index 1806ef7..40e03d6 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -373,6 +373,11 @@
"loading_project": "Loading project...",
"stopping_gracefully": "Stopping gracefully...",
"stop_processing": "Stop Processing",
+ "leave_while_running_title": "Auto Match is running",
+ "leave_while_running_body": "Leaving this project stops the run now. What it has matched so far is saved.",
+ "leave_while_running_body_unsaved": "This project isn't saved yet. Leaving stops the run now and discards the project and its results.",
+ "stay": "Stay",
+ "stop_and_leave": "Stop and leave",
"auto_match_all_including_approved_confirm": "I understand that {{approvedCount}} approved decisions will be overwritten",
"auto_match_selected_rows_note_no_count": "Select rows in the left panel to enable this option",
"auto_match_rows_failed": "Matching failed for {{count}} input row(s), so they're missing candidates: {{details}}. Rows count from the first data row of your file. Run AutoMatch on those rows again to retry them.",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index 2aa5ce3..3ef8477 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -359,6 +359,11 @@
"loading_project": "Cargando proyecto...",
"stopping_gracefully": "Deteniendo suavemente...",
"stop_processing": "Detener Procesamiento",
+ "leave_while_running_title": "Auto Match está en ejecución",
+ "leave_while_running_body": "Salir de este proyecto detiene la ejecución ahora. Lo que ya se ha emparejado se guarda.",
+ "leave_while_running_body_unsaved": "Este proyecto aún no se ha guardado. Salir detiene la ejecución ahora y descarta el proyecto y sus resultados.",
+ "stay": "Quedarse",
+ "stop_and_leave": "Detener y salir",
"auto_match_all_including_approved_confirm": "Entiendo que se sobrescribirán {{approvedCount}} decisiones aprobadas",
"auto_match_selected_rows_note_no_count": "Seleccione filas en el panel izquierdo para habilitar esta opción",
"auto_match_rows_failed": "La coincidencia falló en {{count}} fila(s) de entrada, así que les faltan candidatos: {{details}}. Las filas se cuentan desde la primera fila de datos de tu archivo. Vuelve a ejecutar AutoMatch en esas filas para reintentarlo.",
diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json
index cc0cd1b..ccf4006 100644
--- a/src/i18n/locales/pt-BR/translations.json
+++ b/src/i18n/locales/pt-BR/translations.json
@@ -373,6 +373,11 @@
"loading_project": "Carregando projeto...",
"stopping_gracefully": "Encerrando com segurança...",
"stop_processing": "Parar Processamento",
+ "leave_while_running_title": "O Auto Match está em execução",
+ "leave_while_running_body": "Sair deste projeto interrompe a execução agora. O que já foi correspondido é salvo.",
+ "leave_while_running_body_unsaved": "Este projeto ainda não foi salvo. Sair interrompe a execução agora e descarta o projeto e seus resultados.",
+ "stay": "Ficar",
+ "stop_and_leave": "Parar e sair",
"auto_match_all_including_approved_confirm": "Entendo que {{approvedCount}} decisões aprovadas serão sobrescritas",
"auto_match_selected_rows_note_no_count": "Selecione linhas no painel esquerdo para habilitar esta opção",
"auto_match_rows_failed": "O AutoMatch falhou para {{count}} linha(s) de entrada, então elas estão sem candidatos: {{details}}. As linhas contam a partir da primeira linha de dados do seu arquivo. Execute o AutoMatch nessas linhas novamente para tentar de novo.",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index feedf28..acf56c8 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -371,6 +371,11 @@
"loading_project": "正在加载项目...",
"stopping_gracefully": "正在优雅地停止...",
"stop_processing": "停止处理",
+ "leave_while_running_title": "自动匹配正在运行",
+ "leave_while_running_body": "离开此项目会立即停止运行。已匹配的结果会被保存。",
+ "leave_while_running_body_unsaved": "此项目尚未保存。离开会立即停止运行,并丢弃该项目及其结果。",
+ "stay": "留下",
+ "stop_and_leave": "停止并离开",
"auto_match_all_including_approved_confirm": "我明白将覆盖 {{approvedCount}} 个已批准的决策",
"auto_match_selected_rows_note_no_count": "请在左侧面板选择行以启用此选项",
"auto_match_rows_failed": "{{count}} 个输入行匹配失败,缺少候选项:{{details}}。行号从文件的第一个数据行开始计数。请对这些行重新运行 AutoMatch 以重试。",
diff --git a/src/services/APIService.js b/src/services/APIService.js
index 8d63ade..725c901 100644
--- a/src/services/APIService.js
+++ b/src/services/APIService.js
@@ -176,6 +176,13 @@ class APIService {
});
};
+ // For a request sent as the page closes: fetch's keepalive outlives the page,
+ // axios's XHR doesn't. Best effort, never rejects.
+ keepalive(method, data, token) {
+ const { url, headers } = this.getRequest(method, data, token, {});
+ return fetch(url, {method, headers, body: JSON.stringify(data), keepalive: true}).catch(() => null);
+ }
+
getHeaders(token, headers) {
token = token || (token !== false ? currentUserToken() : token);
const obj = defaults(headers, this.headers);
diff --git a/src/services/__tests__/retry.test.js b/src/services/__tests__/retry.test.js
new file mode 100644
index 0000000..4e47785
--- /dev/null
+++ b/src/services/__tests__/retry.test.js
@@ -0,0 +1,30 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { retryWithBackoff } from '../retry.js'
+
+test('retryWithBackoff: cancelled during the wait, it sends nothing more', async () => {
+ let cancelled = false
+ let attempts = 0
+ const fn = async () => {
+ attempts++
+ // The user leaves while the retry waits.
+ setTimeout(() => { cancelled = true }, 0)
+ throw new Error('connect failed')
+ }
+
+ await assert.rejects(retryWithBackoff(fn, {baseDelayMs: 5, jitterFactor: 0, isRetryable: () => true, isCancelled: () => cancelled}), /connect failed/)
+ assert.equal(attempts, 1)
+})
+
+test('retryWithBackoff: retries a retryable failure', async () => {
+ let attempts = 0
+ const result = await retryWithBackoff(async () => {
+ if(++attempts < 3)
+ throw new Error('busy')
+ return 'ok'
+ }, {baseDelayMs: 1, jitterFactor: 0, isRetryable: () => true})
+
+ assert.equal(result, 'ok')
+ assert.equal(attempts, 3)
+})
diff --git a/src/services/retry.js b/src/services/retry.js
index 2150783..d39b79f 100644
--- a/src/services/retry.js
+++ b/src/services/retry.js
@@ -19,6 +19,8 @@ export const retryWithBackoff = async (fn, { maxRetries = 2, baseDelayMs = 3000,
const base = baseDelayMs * Math.pow(backoffFactor, attempt);
const delay = base * (1 - jitterFactor + Math.random() * jitterFactor * 2);
await sleep(delay);
+ // Cancelled while waiting: don't send it again.
+ if (isCancelled()) throw err;
}
}
};