fix: close the other two routes back into a run-less import step

Round 4 gated the setup step's own Back button when the completed run was no
longer in memory, but that is the least used of three ways back into step 3, and
both reviewers landed on the same gap.

The stepper renders every earlier step as reachable, and `importIsRunning()` is
false after a reload, so its "Import" tab walked straight there. And `onFinish`
pushed step 4 over step 3, leaving the browser's own Back pointing at the same
place.

After a reload there is nothing to hand back: the executor was in memory, and a
clean finish clears the parking, so step 3 mounted with `resume` undefined and
offered a fresh run — re-importing a bundle already in (a wall of path
conflicts), or on a new workspace re-running a create that now fails as already
existing, with no Delete offered because that execution never made it.

`ImportWizardSteps` takes a `lowestStep`, which the page raises to 4 exactly
when the run is gone, and the step-3 → 4 transition replaces rather than pushes.

Verified against a real reload: the stepper stays on step 4 and says why, and
browser Back lands on step 2 with no runnable import.

Also adds the migration-phase abandonment assertion the review asked for — that
no task is left on `running` when a run stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-24 09:53:46 +02:00
parent a07a63ae5c
commit ee2a50022a
3 changed files with 42 additions and 3 deletions
@@ -17,9 +17,17 @@
step: WizardStep
/** Whether this import has a setup step at all — most projects do not. */
hasSetup?: boolean
/**
* The lowest step still worth returning to. Defaults to the first. The page raises it
* past the import once that import has landed and the run behind it can no longer be
* recovered — after a reload on the setup step, where the executor was in memory and
* the parking a clean finish cleared. Step 3 would otherwise mount with nothing to
* resume and offer to run the whole bundle again.
*/
lowestStep?: number
}
let { step, hasSetup = false }: Props = $props()
let { step, hasSetup = false, lowestStep = 1 }: Props = $props()
// Most projects ship no data table migrations, so the wizard is three steps and
// says so. A fourth appears only once there is something to configure.
@@ -32,6 +40,10 @@
// skip ahead, since each step decides what the next one asks.
function onStepClick(index: number) {
if (index >= step - 1) return
if (index + 1 < lowestStep) {
sendUserToast('The project is already imported. There is nothing to go back to.', true)
return
}
// An import in flight owns the page: stepping back unmounts the step that is
// awaiting the migration review, which would leave the run with no controls
// and no way to resolve.
@@ -104,6 +104,19 @@ describe('abandoning mid-import', () => {
expect(resumableImport('calendly', 'ws-a')).toBe(true)
})
it('stops the migrate row spinning when it is abandoned mid-migration', async () => {
const run = new ImportExecution(PLAN, deps)
// Abandon once the write loop has started, which is where `onMigrationsStart` has
// already flipped the row to running in a real run.
hooks.afterFirstItem = () => {
run.abandon()
}
await run.run()
// A row left on `running` reads as work still in progress on a run that has stopped.
expect(run.tasks.some((t) => t.status === 'running')).toBe(false)
expect(run.done).toBe(false)
})
it('clears it on a clean finish, so a later import reaches its own create', async () => {
parkImport({ slug: 'calendly', workspaceId: 'ws-a' })
const run = new ImportExecution(PLAN, deps)
@@ -328,7 +328,15 @@
{/if}
</span>
{/snippet}
<ImportWizardSteps {step} hasSetup={setupNeeded || step === 4} />
<!-- Once the run is gone, the import step behind us has nothing to resume and would
offer to run the whole bundle again — over items already in, or over a create
whose workspace now exists. That is only reachable after a reload on step 4, so
the floor rises exactly then. -->
<ImportWizardSteps
{step}
hasSetup={setupNeeded || step === 4}
lowestStep={step === 4 && !execution ? 4 : 1}
/>
{#if step === 1}
<div class="flex flex-col gap-6">
@@ -513,7 +521,13 @@
setupPending={setupNeeded}
{setupUndecided}
onFolderChange={(folder) => go({ folder }, 3, { replace: true })}
onFinish={() => (setupNeeded ? go({}, 4) : finish())}
onFinish={() =>
setupNeeded
? // Replaces rather than pushes: after a reload on step 4 the run is gone, and
// a step-3 entry in history is a browser-Back route to the same fresh import
// the stepper is now blocked from reaching.
go({}, 4, { replace: true })
: finish()}
onBack={() => go({}, 2)}
onExecution={(e) => (execution = e)}
resume={execution}