Fix: delay iframe DOM removal, handle onafterprint error for print in FF (#11237)

This commit is contained in:
shamoon 2025-10-30 18:26:42 -07:00 committed by GitHub
commit 8b9ca75a90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 100 additions and 57 deletions

View File

@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => {
mockContentWindow.onafterprint(new Event('afterprint')) mockContentWindow.onafterprint(new Event('afterprint'))
} }
tick(500)
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => {
) )
}) })
it('should show error toast if printing throws inside iframe', fakeAsync(() => { const iframePrintErrorCases: Array<{
initNormally() description: string
thrownError: Error
expectToast: boolean
}> = [
{
description: 'should show error toast if printing throws inside iframe',
thrownError: new Error('focus failed'),
expectToast: true,
},
{
description:
'should suppress toast if cross-origin afterprint error occurs',
thrownError: new DOMException(
'Accessing onafterprint triggered a cross-origin violation',
'SecurityError'
),
expectToast: false,
},
]
const appendChildSpy = jest iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
.spyOn(document.body, 'appendChild') it(
.mockImplementation((node: Node) => node) description,
const removeChildSpy = jest fakeAsync(() => {
.spyOn(document.body, 'removeChild') initNormally()
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
const revokeObjectURLSpy = jest
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError') const appendChildSpy = jest
.spyOn(document.body, 'appendChild')
.mockImplementation((node: Node) => node)
const removeChildSpy = jest
.spyOn(document.body, 'removeChild')
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
const revokeObjectURLSpy = jest
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})
const mockContentWindow = { const toastSpy = jest.spyOn(toastService, 'showError')
focus: jest.fn().mockImplementation(() => {
throw new Error('focus failed')
}),
print: jest.fn(),
onafterprint: null,
}
const mockIframe: any = { const mockContentWindow = {
style: {}, focus: jest.fn().mockImplementation(() => {
src: '', throw thrownError
onload: null, }),
contentWindow: mockContentWindow, print: jest.fn(),
} onafterprint: null,
}
const createElementSpy = jest const mockIframe: any = {
.spyOn(document, 'createElement') style: {},
.mockReturnValue(mockIframe as any) src: '',
onload: null,
contentWindow: mockContentWindow,
}
const blob = new Blob(['test'], { type: 'application/pdf' }) const createElementSpy = jest
component.printDocument() .spyOn(document, 'createElement')
.mockReturnValue(mockIframe as any)
const req = httpTestingController.expectOne( const blob = new Blob(['test'], { type: 'application/pdf' })
`${environment.apiBaseUrl}documents/${doc.id}/download/` component.printDocument()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/`
)
req.flush(blob)
tick()
if (mockIframe.onload) {
mockIframe.onload(new Event('load'))
}
tick(200)
if (expectToast) {
expect(toastSpy).toHaveBeenCalled()
} else {
expect(toastSpy).not.toHaveBeenCalled()
}
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
})
) )
req.flush(blob) })
tick()
if (mockIframe.onload) {
mockIframe.onload(new Event('load'))
}
expect(toastSpy).toHaveBeenCalled()
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
}))
}) })

View File

@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector' import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, of, Subject } from 'rxjs' import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -1452,9 +1452,18 @@ export class DocumentDetailComponent
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
} catch (err) { } catch (err) {
this.toastService.showError($localize`Print failed.`, err) // FF throws cross-origin error on onafterprint
document.body.removeChild(iframe) const isCrossOriginAfterPrintError =
URL.revokeObjectURL(blobUrl) err instanceof DOMException &&
err.message.includes('onafterprint')
if (!isCrossOriginAfterPrintError) {
this.toastService.showError($localize`Print failed.`, err)
}
timer(100).subscribe(() => {
// delay to avoid FF print failure
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
})
} }
} }
}, },