Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Sentry.showReportDialog({
eventId: 'test_id',
onError: error => {
window._reportDialogError = error.message;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';

sentryTest('calls `onError` when the dialog script is blocked', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

// Registered after `getLocalTestUrl` so it takes precedence over the fixture's ingest route
await page.route(/\/api\/embed\/error-page\//, route => route.abort('blockedbyclient'));

await page.goto(url);

const errorMessage = await page.waitForFunction(() => (window as any)._reportDialogError);

expect(await errorMessage.jsonValue()).toBe('Failed to load the report dialog script');
});
21 changes: 19 additions & 2 deletions packages/browser/src/report-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,30 @@ export function showReportDialog(options: ReportDialogOptions = {}): void {
eventId: options.eventId || lastEventId(),
};

const { onLoad, onClose, onError } = mergedOptions;

// The endpoint rejects requests without an event ID, and a failed script load hides the reason
if (!mergedOptions.eventId) {
DEBUG_BUILD && debug.error('[showReportDialog] No event ID');
onError?.(new Error('No event ID to show the report dialog for'));
return;
Comment on lines +43 to +45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: The docs say, onError only reports load errors. Wondering if we should adjust docs (probably) or remove the invocation here, wdyt?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the docs in dd22082

}
Comment on lines +42 to +46

@logaretm logaretm Sep 28, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think rejecting on missing eventId is a bit of a behavior change, probably breaking.

But it never worked in the first place 🤔

EDIT: Never mind, if they don't have the callback then nothing happens. Disregard.


const script = WINDOW.document.createElement('script');
script.async = true;
script.crossOrigin = 'anonymous';
script.src = getReportDialogEndpoint(dsn, mergedOptions);

const { onLoad, onClose } = mergedOptions;

if (onLoad) {
script.onload = onLoad;
}

if (onError) {
script.onerror = () => {

@SimonSchick SimonSchick Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't the error handler need to be registered before src is set? Same for onLoad really.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To the best of my knowledge, the script starts loading only once its attached to the DOM, so when we set these properties makes no difference.

This is different from when you write a script tag directly to the DOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, scripts load only when added to the DOM, unlike images.

onError(new Error('Failed to load the report dialog script'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be prudent to propagate the original error via { cause: ev.error } depending on the compatibility you are targeting.

@andreiborza andreiborza Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tested this and browsers just give back a plain Event here, there's no .error field. That being said, there's one case where the report dialog errors with a 400 that would be lumped in with this hardcoded error message.

};
}
Comment thread
sentry[bot] marked this conversation as resolved.

if (onClose) {
const reportDialogClosedMessageHandler = (event: MessageEvent): void => {
if (event.data === '__sentry_reportdialog_closed__') {
Expand All @@ -58,6 +71,10 @@ export function showReportDialog(options: ReportDialogOptions = {}): void {
}
};
WINDOW.addEventListener('message', reportDialogClosedMessageHandler);
// A dialog that never loads never closes, so the listener would stay forever
script.addEventListener('error', () => {
WINDOW.removeEventListener('message', reportDialogClosedMessageHandler);
});
}

injectionPoint.appendChild(script);
Expand Down
49 changes: 44 additions & 5 deletions packages/browser/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe('SentryBrowser', () => {
getCurrentScope().setUser(EX_USER);
setCurrentClient(client);

showReportDialog();
showReportDialog({ eventId: 'foobar' });

expect(getReportDialogEndpoint).toHaveBeenCalledTimes(1);
expect(getReportDialogEndpoint).toHaveBeenCalledWith(
Expand Down Expand Up @@ -136,7 +136,7 @@ describe('SentryBrowser', () => {
setCurrentClient(client);

const DIALOG_OPTION_USER = { email: 'option@example.com' };
showReportDialog({ user: DIALOG_OPTION_USER });
showReportDialog({ eventId: 'foobar', user: DIALOG_OPTION_USER });

expect(getReportDialogEndpoint).toHaveBeenCalledTimes(1);
expect(getReportDialogEndpoint).toHaveBeenCalledWith(
Expand Down Expand Up @@ -170,7 +170,7 @@ describe('SentryBrowser', () => {
it('should call `onClose` when receiving `__sentry_reportdialog_closed__` MessageEvent', async () => {
const onClose = vi.fn();

showReportDialog({ onClose });
showReportDialog({ eventId: 'foobar', onClose });

await waitForPostMessage('__sentry_reportdialog_closed__');
expect(onClose).toHaveBeenCalledTimes(1);
Expand All @@ -185,7 +185,7 @@ describe('SentryBrowser', () => {
throw new Error();
});

showReportDialog({ onClose });
showReportDialog({ eventId: 'foobar', onClose });

await waitForPostMessage('__sentry_reportdialog_closed__');
expect(onClose).toHaveBeenCalledTimes(1);
Expand All @@ -198,14 +198,53 @@ describe('SentryBrowser', () => {
it('should not call `onClose` for other MessageEvents', async () => {
const onClose = vi.fn();

showReportDialog({ onClose });
showReportDialog({ eventId: 'foobar', onClose });

await waitForPostMessage('some_message');
expect(onClose).not.toHaveBeenCalled();

await waitForPostMessage('__sentry_reportdialog_closed__');
expect(onClose).toHaveBeenCalledTimes(1);
});

it('should remove the `onClose` listener when the script fails to load', async () => {
const onClose = vi.fn();

showReportDialog({ eventId: 'foobar', onClose });

const script = WINDOW.document.head.lastElementChild as HTMLScriptElement;
script.dispatchEvent(new Event('error'));

await waitForPostMessage('__sentry_reportdialog_closed__');
expect(onClose).not.toHaveBeenCalled();
});
});

describe('onError', () => {
it('should call `onError` when the script fails to load', () => {
const onError = vi.fn();

showReportDialog({ eventId: 'foobar', onError });

const script = WINDOW.document.head.lastElementChild as HTMLScriptElement;
script.dispatchEvent(new Event('error'));

expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(new Error('Failed to load the report dialog script'));
});

it('should call `onError` and not inject the script without an event ID', () => {
const onError = vi.fn();
const appendChildSpy = vi.spyOn(WINDOW.document.head, 'appendChild');

showReportDialog({ onError });

expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(new Error('No event ID to show the report dialog for'));
expect(appendChildSpy).not.toHaveBeenCalled();

appendChildSpy.mockRestore();
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function getReportDialogEndpoint(dsnLike: DsnLike, dialogOptions: ReportD
continue;
}

if (key === 'onClose') {
if (key === 'onClose' || key === 'onError') {
continue;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/report-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ export interface ReportDialogOptions extends Record<string, unknown> {
onLoad?(this: void): void;
/** Callback after reportDialog closed */
onClose?(this: void): void;
/**
* Callback if the reportDialog cannot be shown. This happens when:
* - there is no event ID (no `eventId` option and no event captured yet)
* - the dialog script fails to load (e.g. blocked by an ad blocker or a network error)
*/
onError?(this: void, error: Error): void;
}
6 changes: 6 additions & 0 deletions packages/core/test/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ describe('API', () => {
{ onClose: () => {} },
'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123',
],
[
'with Public DSN and onError callback',
dsnPublic,
{ onError: () => {} },
'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123',
],
])(
'%s',
(
Expand Down
Loading