Skip to content

Commit 27b7f5c

Browse files
committed
merge main
2 parents f30d2e9 + 5bd61e6 commit 27b7f5c

File tree

4 files changed

+78
-62
lines changed

4 files changed

+78
-62
lines changed

app/forms/image-upload.tsx

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,6 @@ export function Component() {
184184
const queryClient = useApiQueryClient()
185185
const { project } = useProjectSelector()
186186

187-
// Note: abort currently only works if it fires during the upload file step.
188-
// We could make it work between the other steps by calling
189-
// `abortController.throwIfAborted()` after each one. We could technically
190-
// plumb through the signal to the requests themselves, but they complete so
191-
// quickly it's probably not necessary.
192-
193187
// The state in this component is very complex because we are doing a bunch of
194188
// requests in order, all of which can fail, plus the whole thing can be
195189
// aborted. We have the usual form state, plus an additional validation step
@@ -250,8 +244,19 @@ export function Component() {
250244
// separate so we can distinguish between cleanup due to error vs. cleanup after success
251245
const stopImportCleanup = useApiMutation('diskBulkWriteImportStop')
252246
const finalizeDiskCleanup = useApiMutation('diskFinalizeImport')
253-
const deleteDiskCleanup = useApiMutation('diskDelete')
254-
const deleteSnapshotCleanup = useApiMutation('snapshotDelete')
247+
// in production these invalidations are unlikely to matter, but they help a
248+
// lot in the tests when we check the disk list after canceling to make sure
249+
// the temp resources got deleted
250+
const deleteDiskCleanup = useApiMutation('diskDelete', {
251+
onSuccess() {
252+
queryClient.invalidateQueries('diskList')
253+
},
254+
})
255+
const deleteSnapshotCleanup = useApiMutation('snapshotDelete', {
256+
onSuccess() {
257+
queryClient.invalidateQueries('snapshotList')
258+
},
259+
})
255260

256261
const cleanupMutations = [
257262
stopImportCleanup,
@@ -270,31 +275,36 @@ export function Component() {
270275
const snapshot = useRef<Snapshot | null>(null)
271276
const disk = useRef<Disk | null>(null)
272277

273-
// if closeModal runs during bulk upload due to a cancel, cancelEverything
274-
// causes an abort of the bulk upload, which throws an error to onSubmit's
275-
// catch, which calls `cleanup`. so when we call cleanup here, it will be a
276-
// double cleanup. we could get rid of this one, but for the rare cancel *not*
277-
// during bulk upload we will still want to call cleanup. rather than
278-
// coordinating when to cleanup, we make cleanup idempotent by having it check
279-
// whether it has already been run, or more concretely before each action,
280-
// check whether it needs to be done
281278
function closeModal() {
282279
if (allDone) {
283280
backToImages()
284281
return
285282
}
286283

287-
// if we're still going, need to confirm cancelation. if we have an error,
284+
// if we're still going, need to confirm cancellation. if we have an error,
288285
// everything is already stopped
289286
if (modalError || confirm('Are you sure? Closing the modal will cancel the upload.')) {
287+
// Note we don't run cleanup() here -- cancelEverything triggers an
288+
// abort, which gets caught by the try/catch in the onSubmit on the upload
289+
// form, which does the cleanup. We used to call cleanup here and used
290+
// error-prone state logic to avoid it running twice.
291+
//
292+
// Because we are working with a closed-over copy of allDone, there is
293+
// a possibility that the upload finishes while the user is looking at
294+
// the confirm modal, in which case cancelEverything simply won't do
295+
// anything. The finally{} in onSubmit clears out the abortController so
296+
// cancelEverything() is a noop.
290297
cancelEverything()
291-
// TODO: probably shouldn't await this, but we do need to catch errors
292-
cleanup()
293298
resetMainFlow()
294299
setModalOpen(false)
295300
}
296301
}
297302

303+
// Aborting works for steps other than file upload despite the
304+
// signal not being used directly in the former because we call
305+
// `abortController.throwIfAborted()` after each step. We could technically
306+
// plumb through the signal to the requests themselves, but they complete so
307+
// quickly it's probably not necessary.
298308
function cancelEverything() {
299309
abortController.current?.abort(ABORT_ERROR)
300310
}
@@ -306,14 +316,8 @@ export function Component() {
306316
setSyntheticUploadState(initSyntheticState)
307317
}
308318

309-
const cleaningUp = useRef(false)
310-
311319
/** If a snapshot or disk was created, clean it up*/
312320
async function cleanup() {
313-
// don't run if already running
314-
if (cleaningUp.current) return
315-
cleaningUp.current = true
316-
317321
if (snapshot.current) {
318322
await deleteSnapshotCleanup.mutateAsync({ path: { snapshot: snapshot.current.id } })
319323
snapshot.current = null
@@ -335,7 +339,6 @@ export function Component() {
335339
await deleteDiskCleanup.mutateAsync({ path: { disk: disk.current.id } })
336340
disk.current = null
337341
}
338-
cleaningUp.current = false
339342
}
340343

341344
async function onSubmit({
@@ -500,10 +503,6 @@ export function Component() {
500503
title="Upload image"
501504
onDismiss={backToImages}
502505
onSubmit={async (values) => {
503-
// every submit needs its own AbortController because they can't be
504-
// reset
505-
abortController.current = new AbortController()
506-
507506
setFormError(null)
508507

509508
// check that image name isn't taken before starting the whole thing
@@ -532,17 +531,29 @@ export function Component() {
532531
return
533532
}
534533

534+
// every submit needs its own AbortController because they can't be
535+
// reset
536+
abortController.current = new AbortController()
537+
535538
try {
536539
await onSubmit(values)
537540
} catch (e) {
538541
if (e !== ABORT_ERROR) {
539542
console.error(e)
540543
setModalError('Something went wrong. Please try again.')
544+
// abort anything in flight in case
545+
cancelEverything()
541546
}
542-
cancelEverything()
543547
// user canceled
544548
await cleanup()
545549
// TODO: if we get here, show failure state in the upload modal
550+
} finally {
551+
// Clear the abort controller. This is aimed at the case where the
552+
// user clicks cancel and then stares at the confirm modal without
553+
// clicking for so long that the upload manages to finish, which means
554+
// there's no longer anything to cancel. If abortController is gone,
555+
// cancelEverything is a noop.
556+
abortController.current = null
546557
}
547558
}}
548559
loading={formLoading}

mock-api/msw/handlers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export const handlers = makeHandlers({
186186
),
187187
}
188188
},
189-
diskBulkWriteImportStart: ({ path, query }) => {
189+
async diskBulkWriteImportStart({ path, query }) {
190190
const disk = lookup.disk({ ...path, ...query })
191191

192192
if (disk.name === 'import-start-500') throw 500
@@ -195,20 +195,21 @@ export const handlers = makeHandlers({
195195
throw 'Can only enter state importing_from_bulk_write from import_ready'
196196
}
197197

198-
// throw 400
198+
await delay(1000) // slow it down for the tests
199199

200200
db.diskBulkImportState.set(disk.id, { blocks: {} })
201201
disk.state = { state: 'importing_from_bulk_writes' }
202202
return 204
203203
},
204-
diskBulkWriteImportStop: ({ path, query }) => {
204+
async diskBulkWriteImportStop({ path, query }) {
205205
const disk = lookup.disk({ ...path, ...query })
206206

207207
if (disk.name === 'import-stop-500') throw 500
208208

209209
if (disk.state.state !== 'importing_from_bulk_writes') {
210210
throw 'Can only stop import for disk in state importing_from_bulk_write'
211211
}
212+
await delay(1000) // slow it down for the tests
212213

213214
db.diskBulkImportState.delete(disk.id)
214215
disk.state = { state: 'import_ready' }

test/e2e/image-upload.e2e.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -123,41 +123,45 @@ test.describe('Image upload', () => {
123123
await expectVisible(page, [fileRequired])
124124
})
125125

126-
test('cancel', async ({ page, browserName }) => {
127-
// eslint-disable-next-line playwright/no-skipped-test
128-
test.skip(browserName === 'webkit', 'safari. stop this')
129-
130-
await fillForm(page, 'new-image')
126+
const cancelStates = [
127+
'Put disk in import mode',
128+
'Upload image file',
129+
'Get disk out of import mode',
130+
// 'Finalize disk and create snapshot',
131+
]
131132

132-
await page.getByRole('button', { name: 'Upload image' }).click()
133+
for (const state of cancelStates) {
134+
test(`cancel in state '${state}'`, async ({ page }) => {
135+
await fillForm(page, 'new-image')
133136

134-
const progressModal = page.getByRole('dialog', { name: 'Image upload progress' })
135-
await expect(progressModal).toBeVisible()
137+
await page.getByRole('button', { name: 'Upload image' }).click()
136138

137-
// wait to be in the middle of upload
138-
const uploadStep = page.getByTestId('upload-step: Upload image file')
139-
await expect(uploadStep).toHaveAttribute('data-status', 'running')
139+
const progressModal = page.getByRole('dialog', { name: 'Image upload progress' })
140+
await expect(progressModal).toBeVisible()
140141

141-
// form is disabled and semi-hidden
142-
// await expectNotVisible(page, ['role=textbox[name="Name"]'])
142+
// wait to be in the middle of upload
143+
const uploadStep = page.getByTestId(`upload-step: ${state}`)
144+
await expect(uploadStep).toHaveAttribute('data-status', 'running')
143145

144-
page.on('dialog', (dialog) => dialog.accept()) // click yes on the are you sure prompt
145-
await progressModal.getByRole('button', { name: 'Cancel' }).click()
146+
// form is disabled and semi-hidden
147+
// await expectNotVisible(page, ['role=textbox[name="Name"]'])
146148

147-
// modal has closed
148-
await expect(progressModal).toBeHidden()
149+
page.on('dialog', (dialog) => dialog.accept()) // click yes on the are you sure prompt
150+
await progressModal.getByRole('button', { name: 'Cancel' }).click()
149151

150-
// form's back
151-
await expectVisible(page, ['role=textbox[name="Name"]'])
152+
// modal has closed
153+
await expect(progressModal).toBeHidden()
152154

153-
// get out of the form
154-
await page.click('text=Cancel')
155+
// form's back
156+
await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible()
155157

156-
// TODO: go to disks and make sure the tmp one got cleaned up
157-
// await page.click('role=link[name="Disks"]')
158-
// await expectVisible(page, ['role=cell[name="disk-1"]'])
159-
// await expectNotVisible(page, ['role=cell[name=tmp]'])
160-
})
158+
// get out of the form and go to the disks page to check it's not there
159+
await page.getByRole('button', { name: 'Cancel' }).click()
160+
await page.getByRole('link', { name: 'Disks' }).click()
161+
await expect(page.getByRole('cell', { name: 'disk-1', exact: true })).toBeVisible()
162+
await expect(page.getByRole('cell', { name: 'tmp' })).toBeHidden()
163+
})
164+
}
161165

162166
// testing the onFocusOutside fix
163167
test('cancel canceling', async ({ page, browserName }) => {

tools/deno/api-diff.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,5 @@ const headPath = (await genForCommit(head, args.force)) + '/Api.ts'
130130
await $`${diffTool} ${basePath} ${headPath} || true`
131131

132132
// useful if you want to open the file directly in an editor
133-
console.log('Before:', basePath)
134-
console.log('After: ', headPath)
133+
console.info('Before:', basePath)
134+
console.info('After: ', headPath)

0 commit comments

Comments
 (0)