From 70d2ab4450c807a377d9721b42cf2264d624bfe5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 23 Apr 2026 22:42:35 -0400 Subject: [PATCH 1/6] feat(feedback): allow error messages to be customized Adds five new text options (errorEmptyMessageText, errorNoClientText, errorTimeoutText, errorForbiddenText, errorGenericText) to FeedbackTextConfiguration so consumers can translate or reword the widget's error messages. sendFeedback now throws/rejects with stable codes (typed via FeedbackErrorCode) and the widget maps those codes to the configured text. Closes #14687 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/src/types-hoist/feedback/config.ts | 25 +++++++++++++++++++ packages/feedback/src/constants/index.ts | 8 ++++++ packages/feedback/src/core/integration.ts | 16 ++++++++++++ packages/feedback/src/core/sendFeedback.ts | 16 ++++++------ .../feedback/src/modal/components/Form.tsx | 19 ++++++++++++-- .../feedback/src/util/createFeedbackError.ts | 10 ++++++++ .../feedback/test/core/sendFeedback.test.ts | 14 +++-------- 7 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 packages/feedback/src/util/createFeedbackError.ts diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index f6a90c7c5b73..5d7db7636996 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -191,6 +191,31 @@ export interface FeedbackTextConfiguration { * The label for the button that removed a highlight/hidden section of the screenshot. */ removeHighlightText: string; + + /** + * Error text shown when feedback submission is attempted with an empty message + */ + errorEmptyMessageText: string; + + /** + * Error text shown when the Sentry client is not set up + */ + errorNoClientText: string; + + /** + * Error text shown when the feedback submission times out (after 30s) + */ + errorTimeoutText: string; + + /** + * Error text shown when the feedback submission is blocked because the domain is not allowed (HTTP 403) + */ + errorForbiddenText: string; + + /** + * Error text shown when the feedback submission fails for any other reason (e.g. network error, ad-blocker) + */ + errorGenericText: string; } /** diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index d18392258417..309bba7490ab 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -26,6 +26,14 @@ export const HIGHLIGHT_TOOL_TEXT = 'Highlight'; export const HIDE_TOOL_TEXT = 'Hide'; export const REMOVE_HIGHLIGHT_TEXT = 'Remove'; +export const ERROR_EMPTY_MESSAGE_TEXT = 'Unable to submit feedback with empty message'; +export const ERROR_NO_CLIENT_TEXT = 'No client setup, cannot send feedback.'; +export const ERROR_TIMEOUT_TEXT = 'Unable to determine if Feedback was correctly sent.'; +export const ERROR_FORBIDDEN_TEXT = + 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.'; +export const ERROR_GENERIC_TEXT = + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.'; + export const FEEDBACK_WIDGET_SOURCE = 'widget'; export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 1dc418ed131f..a57be211af69 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +/* eslint-disable complexity */ import type { FeedbackInternalOptions, @@ -15,6 +16,11 @@ import { DOCUMENT, EMAIL_LABEL, EMAIL_PLACEHOLDER, + ERROR_EMPTY_MESSAGE_TEXT, + ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC_TEXT, + ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT_TEXT, FORM_TITLE, HIDE_TOOL_TEXT, HIGHLIGHT_TOOL_TEXT, @@ -119,6 +125,11 @@ export const buildFeedbackIntegration = ({ highlightToolText = HIGHLIGHT_TOOL_TEXT, hideToolText = HIDE_TOOL_TEXT, removeHighlightText = REMOVE_HIGHLIGHT_TEXT, + errorEmptyMessageText = ERROR_EMPTY_MESSAGE_TEXT, + errorNoClientText = ERROR_NO_CLIENT_TEXT, + errorTimeoutText = ERROR_TIMEOUT_TEXT, + errorForbiddenText = ERROR_FORBIDDEN_TEXT, + errorGenericText = ERROR_GENERIC_TEXT, // FeedbackCallbacks onFormOpen, @@ -164,6 +175,11 @@ export const buildFeedbackIntegration = ({ highlightToolText, hideToolText, removeHighlightText, + errorEmptyMessageText, + errorNoClientText, + errorTimeoutText, + errorForbiddenText, + errorGenericText, onFormClose, onFormOpen, diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index 712da5c269bf..d33748e237ae 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -1,6 +1,8 @@ import type { Event, EventHint, SendFeedback, SendFeedbackParams, TransportMakeRequestResponse } from '@sentry/core'; import { captureFeedback, getClient, getCurrentScope, getLocationHref } from '@sentry/core'; import { FEEDBACK_API_SOURCE } from '../constants'; +import type { FeedbackErrorCode } from '../util/createFeedbackError'; +import { createFeedbackError } from '../util/createFeedbackError'; /** * Public API to send a Feedback item to Sentry @@ -10,14 +12,14 @@ export const sendFeedback: SendFeedback = ( hint: EventHint & { includeReplay?: boolean } = { includeReplay: true }, ): Promise => { if (!params.message) { - throw new Error('Unable to submit feedback with empty message'); + throw createFeedbackError('ERROR_EMPTY_MESSAGE'); } // We want to wait for the feedback to be sent (or not) const client = getClient(); if (!client) { - throw new Error('No client setup, cannot send feedback.'); + throw createFeedbackError('ERROR_NO_CLIENT'); } if (params.tags && Object.keys(params.tags).length) { @@ -35,7 +37,7 @@ export const sendFeedback: SendFeedback = ( // We want to wait for the feedback to be sent (or not) return new Promise((resolve, reject) => { // After 30s, we want to clear anyhow - const timeout = setTimeout(() => reject('Unable to determine if Feedback was correctly sent.'), 30_000); + const timeout = setTimeout(() => reject('ERROR_TIMEOUT' satisfies FeedbackErrorCode), 30_000); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { if (event.event_id !== eventId) { @@ -51,14 +53,10 @@ export const sendFeedback: SendFeedback = ( } if (response?.statusCode === 403) { - return reject( - 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.', - ); + return reject('ERROR_FORBIDDEN' satisfies FeedbackErrorCode); } - return reject( - 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', - ); + return reject('ERROR_GENERIC' satisfies FeedbackErrorCode); }); }); }; diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index 0f09e969fee5..011d19dca4ed 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -9,6 +9,7 @@ import type { JSX, VNode } from 'preact'; import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import { useCallback, useState } from 'preact/hooks'; import { FEEDBACK_WIDGET_SOURCE } from '../../constants'; +import type { FeedbackErrorCode } from '../../util/createFeedbackError'; import { DEBUG_BUILD } from '../../util/debug-build'; import { getMissingFields } from '../../util/validate'; @@ -59,7 +60,20 @@ export function Form({ namePlaceholder, submitButtonLabel, isRequiredLabel, + errorEmptyMessageText, + errorNoClientText, + errorTimeoutText, + errorForbiddenText, + errorGenericText, } = options; + + const errorTextByCode: Record = { + ERROR_EMPTY_MESSAGE: errorEmptyMessageText, + ERROR_NO_CLIENT: errorNoClientText, + ERROR_TIMEOUT: errorTimeoutText, + ERROR_FORBIDDEN: errorForbiddenText, + ERROR_GENERIC: errorGenericText, + }; const [isSubmitting, setIsSubmitting] = useState(false); // TODO: set a ref on the form, and whenever an input changes call processForm() and setError() const [error, setError] = useState(null); @@ -131,8 +145,9 @@ export function Form({ onSubmitSuccess(data, eventId); } catch (error) { DEBUG_BUILD && debug.error(error); - setError(error as string); - onSubmitError(error as Error); + const err = error instanceof Error ? error : new Error(String(error)); + setError(errorTextByCode[err.message as FeedbackErrorCode] || errorGenericText); + onSubmitError(err); } } finally { setIsSubmitting(false); diff --git a/packages/feedback/src/util/createFeedbackError.ts b/packages/feedback/src/util/createFeedbackError.ts new file mode 100644 index 000000000000..51024126a822 --- /dev/null +++ b/packages/feedback/src/util/createFeedbackError.ts @@ -0,0 +1,10 @@ +export type FeedbackErrorCode = + | 'ERROR_EMPTY_MESSAGE' + | 'ERROR_NO_CLIENT' + | 'ERROR_TIMEOUT' + | 'ERROR_FORBIDDEN' + | 'ERROR_GENERIC'; + +export function createFeedbackError(reason: FeedbackErrorCode): Error { + return new Error(reason); +} diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index a0cbb084da59..b94d787e8eaf 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -279,9 +279,7 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch( - 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', - ); + ).rejects.toMatch('ERROR_GENERIC'); }); it('handles 0 transport error', async () => { @@ -296,9 +294,7 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch( - 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', - ); + ).rejects.toMatch('ERROR_GENERIC'); }); it('handles 403 transport error', async () => { @@ -313,9 +309,7 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch( - 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.', - ); + ).rejects.toMatch('ERROR_FORBIDDEN'); }); it('handles 200 transport response', async () => { @@ -349,7 +343,7 @@ describe('sendFeedback', () => { vi.advanceTimersByTime(30_000); - await expect(promise).rejects.toMatch('Unable to determine if Feedback was correctly sent.'); + await expect(promise).rejects.toMatch('ERROR_TIMEOUT'); vi.useRealTimers(); }); From 1272fb6790c07a75394c39478dd7c391e4e057d3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 23 Apr 2026 23:13:26 -0400 Subject: [PATCH 2/6] address PR review feedback - Form: use ?? so empty-string text overrides are respected (Copilot) - constants: grammar fix "with an empty message" (Copilot) - sendFeedback: clean up afterSendEvent listener on timeout to avoid leak (Copilot) - tests: cover empty-message and no-client early-return paths (Copilot) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/feedback/src/constants/index.ts | 2 +- packages/feedback/src/core/sendFeedback.ts | 5 ++++- packages/feedback/src/modal/components/Form.tsx | 2 +- packages/feedback/test/core/sendFeedback.test.ts | 15 +++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index 309bba7490ab..cbfafc4f36a6 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -26,7 +26,7 @@ export const HIGHLIGHT_TOOL_TEXT = 'Highlight'; export const HIDE_TOOL_TEXT = 'Hide'; export const REMOVE_HIGHLIGHT_TEXT = 'Remove'; -export const ERROR_EMPTY_MESSAGE_TEXT = 'Unable to submit feedback with empty message'; +export const ERROR_EMPTY_MESSAGE_TEXT = 'Unable to submit feedback with an empty message'; export const ERROR_NO_CLIENT_TEXT = 'No client setup, cannot send feedback.'; export const ERROR_TIMEOUT_TEXT = 'Unable to determine if Feedback was correctly sent.'; export const ERROR_FORBIDDEN_TEXT = diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index d33748e237ae..03f32ad651fe 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -37,7 +37,10 @@ export const sendFeedback: SendFeedback = ( // We want to wait for the feedback to be sent (or not) return new Promise((resolve, reject) => { // After 30s, we want to clear anyhow - const timeout = setTimeout(() => reject('ERROR_TIMEOUT' satisfies FeedbackErrorCode), 30_000); + const timeout = setTimeout(() => { + cleanup(); + reject('ERROR_TIMEOUT' satisfies FeedbackErrorCode); + }, 30_000); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { if (event.event_id !== eventId) { diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index 011d19dca4ed..7627164c3cea 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -146,7 +146,7 @@ export function Form({ } catch (error) { DEBUG_BUILD && debug.error(error); const err = error instanceof Error ? error : new Error(String(error)); - setError(errorTextByCode[err.message as FeedbackErrorCode] || errorGenericText); + setError(errorTextByCode[err.message as FeedbackErrorCode] ?? errorGenericText); onSubmitError(err); } } finally { diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index b94d787e8eaf..be7d686ab422 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -267,6 +267,21 @@ describe('sendFeedback', () => { ]); }); + it('throws when message is empty', () => { + mockSdk(); + expect(() => sendFeedback({ message: '' })).toThrow('ERROR_EMPTY_MESSAGE'); + }); + + it('throws when no client is set up', async () => { + // Isolate in its own scope so the client set up by other tests doesn't bleed in. + // `getClient` reads from the current scope; resetting it here leaves no client. + const { getGlobalScope } = await import('@sentry/core'); + getGlobalScope().setClient(undefined); + getCurrentScope().setClient(undefined); + getIsolationScope().setClient(undefined); + expect(() => sendFeedback({ message: 'mi' })).toThrow('ERROR_NO_CLIENT'); + }); + it('handles 400 transport error', async () => { mockSdk(); vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { From 0c3d1e11fa837eb1517ab373f633bbda9882c81a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 23 Apr 2026 23:20:54 -0400 Subject: [PATCH 3/6] refactor: inject error texts via sendFeedback hint, preserve default messages Addresses review feedback that sendFeedback (a public API) was rejecting with internal codes instead of human-readable strings, changing observable behavior for non-widget consumers. sendFeedback now accepts optional errorMessages overrides via its hint argument, defaulting to the original English strings. The widget wraps sendFeedback to inject its configured text options, so standalone consumers of sendFeedback see the same strings as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/index.ts | 2 ++ .../core/src/types-hoist/feedback/index.ts | 11 +++++-- .../src/types-hoist/feedback/sendFeedback.ts | 11 ++++++- packages/feedback/src/core/integration.ts | 14 ++++++++- packages/feedback/src/core/sendFeedback.ts | 26 ++++++++++------ .../feedback/src/modal/components/Form.tsx | 16 +--------- .../feedback/src/util/createFeedbackError.ts | 30 ++++++++++++++----- .../feedback/test/core/sendFeedback.test.ts | 29 ++++++++++++++---- 8 files changed, 97 insertions(+), 42 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3f8c454e997..1d13b22beb5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -452,6 +452,8 @@ export type { ReplayStopReason, } from './types-hoist/replay'; export type { + FeedbackErrorCode, + FeedbackErrorMessages, FeedbackEvent, FeedbackFormData, FeedbackInternalOptions, diff --git a/packages/core/src/types-hoist/feedback/index.ts b/packages/core/src/types-hoist/feedback/index.ts index 239a44d82543..fd44615e878c 100644 --- a/packages/core/src/types-hoist/feedback/index.ts +++ b/packages/core/src/types-hoist/feedback/index.ts @@ -6,10 +6,17 @@ import type { FeedbackTextConfiguration, FeedbackThemeConfiguration, } from './config'; -import type { FeedbackEvent, SendFeedback, SendFeedbackParams, UserFeedback } from './sendFeedback'; +import type { + FeedbackErrorCode, + FeedbackErrorMessages, + FeedbackEvent, + SendFeedback, + SendFeedbackParams, + UserFeedback, +} from './sendFeedback'; export type { FeedbackFormData } from './form'; -export type { FeedbackEvent, UserFeedback, SendFeedback, SendFeedbackParams }; +export type { FeedbackErrorCode, FeedbackErrorMessages, FeedbackEvent, SendFeedback, SendFeedbackParams, UserFeedback }; /** * The integration's internal `options` member where every value should be set diff --git a/packages/core/src/types-hoist/feedback/sendFeedback.ts b/packages/core/src/types-hoist/feedback/sendFeedback.ts index 63d63b402b50..f1d0fd5d0f3e 100644 --- a/packages/core/src/types-hoist/feedback/sendFeedback.ts +++ b/packages/core/src/types-hoist/feedback/sendFeedback.ts @@ -47,7 +47,16 @@ export interface SendFeedbackParams { tags?: { [key: string]: Primitive }; } +export type FeedbackErrorCode = + | 'ERROR_EMPTY_MESSAGE' + | 'ERROR_NO_CLIENT' + | 'ERROR_TIMEOUT' + | 'ERROR_FORBIDDEN' + | 'ERROR_GENERIC'; + +export type FeedbackErrorMessages = Partial>; + export type SendFeedback = ( params: SendFeedbackParams, - hint?: EventHint & { includeReplay?: boolean }, + hint?: EventHint & { includeReplay?: boolean; errorMessages?: FeedbackErrorMessages }, ) => Promise; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index a57be211af69..a2f94a078976 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -2,11 +2,13 @@ /* eslint-disable complexity */ import type { + FeedbackErrorMessages, FeedbackInternalOptions, FeedbackModalIntegration, FeedbackScreenshotIntegration, Integration, IntegrationFn, + SendFeedback, } from '@sentry/core'; import { addIntegration, debug, isBrowser } from '@sentry/core'; import { @@ -246,6 +248,16 @@ export const buildFeedbackIntegration = ({ debug.error('[Feedback] Missing feedback screenshot integration. Proceeding without screenshots.'); } + const errorMessages: FeedbackErrorMessages = { + ERROR_EMPTY_MESSAGE: options.errorEmptyMessageText, + ERROR_NO_CLIENT: options.errorNoClientText, + ERROR_TIMEOUT: options.errorTimeoutText, + ERROR_FORBIDDEN: options.errorForbiddenText, + ERROR_GENERIC: options.errorGenericText, + }; + const wrappedSendFeedback: SendFeedback = (params, hint) => + sendFeedback(params, { includeReplay: true, ...hint, errorMessages }); + const dialog = modalIntegration.createDialog({ options: { ...options, @@ -259,7 +271,7 @@ export const buildFeedbackIntegration = ({ }, }, screenshotIntegration, - sendFeedback, + sendFeedback: wrappedSendFeedback, shadow: _createShadow(options), }); diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index 03f32ad651fe..204cf1cfa6c7 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -1,25 +1,33 @@ -import type { Event, EventHint, SendFeedback, SendFeedbackParams, TransportMakeRequestResponse } from '@sentry/core'; +import type { + Event, + EventHint, + FeedbackErrorMessages, + SendFeedback, + SendFeedbackParams, + TransportMakeRequestResponse, +} from '@sentry/core'; import { captureFeedback, getClient, getCurrentScope, getLocationHref } from '@sentry/core'; import { FEEDBACK_API_SOURCE } from '../constants'; -import type { FeedbackErrorCode } from '../util/createFeedbackError'; -import { createFeedbackError } from '../util/createFeedbackError'; +import { createFeedbackError, resolveFeedbackErrorMessage } from '../util/createFeedbackError'; /** * Public API to send a Feedback item to Sentry */ export const sendFeedback: SendFeedback = ( params: SendFeedbackParams, - hint: EventHint & { includeReplay?: boolean } = { includeReplay: true }, + hint: EventHint & { includeReplay?: boolean; errorMessages?: FeedbackErrorMessages } = { includeReplay: true }, ): Promise => { + const errorMessages = hint.errorMessages; + if (!params.message) { - throw createFeedbackError('ERROR_EMPTY_MESSAGE'); + throw createFeedbackError('ERROR_EMPTY_MESSAGE', errorMessages); } // We want to wait for the feedback to be sent (or not) const client = getClient(); if (!client) { - throw createFeedbackError('ERROR_NO_CLIENT'); + throw createFeedbackError('ERROR_NO_CLIENT', errorMessages); } if (params.tags && Object.keys(params.tags).length) { @@ -39,7 +47,7 @@ export const sendFeedback: SendFeedback = ( // After 30s, we want to clear anyhow const timeout = setTimeout(() => { cleanup(); - reject('ERROR_TIMEOUT' satisfies FeedbackErrorCode); + reject(resolveFeedbackErrorMessage('ERROR_TIMEOUT', errorMessages)); }, 30_000); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { @@ -56,10 +64,10 @@ export const sendFeedback: SendFeedback = ( } if (response?.statusCode === 403) { - return reject('ERROR_FORBIDDEN' satisfies FeedbackErrorCode); + return reject(resolveFeedbackErrorMessage('ERROR_FORBIDDEN', errorMessages)); } - return reject('ERROR_GENERIC' satisfies FeedbackErrorCode); + return reject(resolveFeedbackErrorMessage('ERROR_GENERIC', errorMessages)); }); }); }; diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index 7627164c3cea..ee4b06b25d88 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -9,7 +9,6 @@ import type { JSX, VNode } from 'preact'; import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import { useCallback, useState } from 'preact/hooks'; import { FEEDBACK_WIDGET_SOURCE } from '../../constants'; -import type { FeedbackErrorCode } from '../../util/createFeedbackError'; import { DEBUG_BUILD } from '../../util/debug-build'; import { getMissingFields } from '../../util/validate'; @@ -60,20 +59,7 @@ export function Form({ namePlaceholder, submitButtonLabel, isRequiredLabel, - errorEmptyMessageText, - errorNoClientText, - errorTimeoutText, - errorForbiddenText, - errorGenericText, } = options; - - const errorTextByCode: Record = { - ERROR_EMPTY_MESSAGE: errorEmptyMessageText, - ERROR_NO_CLIENT: errorNoClientText, - ERROR_TIMEOUT: errorTimeoutText, - ERROR_FORBIDDEN: errorForbiddenText, - ERROR_GENERIC: errorGenericText, - }; const [isSubmitting, setIsSubmitting] = useState(false); // TODO: set a ref on the form, and whenever an input changes call processForm() and setError() const [error, setError] = useState(null); @@ -146,7 +132,7 @@ export function Form({ } catch (error) { DEBUG_BUILD && debug.error(error); const err = error instanceof Error ? error : new Error(String(error)); - setError(errorTextByCode[err.message as FeedbackErrorCode] ?? errorGenericText); + setError(err.message); onSubmitError(err); } } finally { diff --git a/packages/feedback/src/util/createFeedbackError.ts b/packages/feedback/src/util/createFeedbackError.ts index 51024126a822..d7c2c4100f3e 100644 --- a/packages/feedback/src/util/createFeedbackError.ts +++ b/packages/feedback/src/util/createFeedbackError.ts @@ -1,10 +1,24 @@ -export type FeedbackErrorCode = - | 'ERROR_EMPTY_MESSAGE' - | 'ERROR_NO_CLIENT' - | 'ERROR_TIMEOUT' - | 'ERROR_FORBIDDEN' - | 'ERROR_GENERIC'; +import type { FeedbackErrorCode, FeedbackErrorMessages } from '@sentry/core'; +import { + ERROR_EMPTY_MESSAGE_TEXT, + ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC_TEXT, + ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT_TEXT, +} from '../constants'; -export function createFeedbackError(reason: FeedbackErrorCode): Error { - return new Error(reason); +const DEFAULT_MESSAGES: Record = { + ERROR_EMPTY_MESSAGE: ERROR_EMPTY_MESSAGE_TEXT, + ERROR_NO_CLIENT: ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT: ERROR_TIMEOUT_TEXT, + ERROR_FORBIDDEN: ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC: ERROR_GENERIC_TEXT, +}; + +export function resolveFeedbackErrorMessage(code: FeedbackErrorCode, messages?: FeedbackErrorMessages): string { + return messages?.[code] ?? DEFAULT_MESSAGES[code]; +} + +export function createFeedbackError(code: FeedbackErrorCode, messages?: FeedbackErrorMessages): Error { + return new Error(resolveFeedbackErrorMessage(code, messages)); } diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index be7d686ab422..15dcf37914d0 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -269,7 +269,7 @@ describe('sendFeedback', () => { it('throws when message is empty', () => { mockSdk(); - expect(() => sendFeedback({ message: '' })).toThrow('ERROR_EMPTY_MESSAGE'); + expect(() => sendFeedback({ message: '' })).toThrow('Unable to submit feedback with an empty message'); }); it('throws when no client is set up', async () => { @@ -279,7 +279,18 @@ describe('sendFeedback', () => { getGlobalScope().setClient(undefined); getCurrentScope().setClient(undefined); getIsolationScope().setClient(undefined); - expect(() => sendFeedback({ message: 'mi' })).toThrow('ERROR_NO_CLIENT'); + expect(() => sendFeedback({ message: 'mi' })).toThrow('No client setup, cannot send feedback.'); + }); + + it('uses provided errorMessages overrides', async () => { + mockSdk(); + vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { + return Promise.resolve({ statusCode: 403 }); + }); + + await expect( + sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), + ).rejects.toMatch('custom forbidden text'); }); it('handles 400 transport error', async () => { @@ -294,7 +305,9 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch('ERROR_GENERIC'); + ).rejects.toMatch( + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', + ); }); it('handles 0 transport error', async () => { @@ -309,7 +322,9 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch('ERROR_GENERIC'); + ).rejects.toMatch( + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', + ); }); it('handles 403 transport error', async () => { @@ -324,7 +339,9 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch('ERROR_FORBIDDEN'); + ).rejects.toMatch( + 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.', + ); }); it('handles 200 transport response', async () => { @@ -358,7 +375,7 @@ describe('sendFeedback', () => { vi.advanceTimersByTime(30_000); - await expect(promise).rejects.toMatch('ERROR_TIMEOUT'); + await expect(promise).rejects.toMatch('Unable to determine if Feedback was correctly sent.'); vi.useRealTimers(); }); From c6dda3bcab4b0a4db2f843bc5fecb9f25e3dab4f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 00:07:56 -0400 Subject: [PATCH 4/6] bump size limits for feedback error-message overrides Co-Authored-By: Claude Opus 4.7 (1M context) --- .size-limit.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index cad516a0a49a..22325b86b2ae 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -96,14 +96,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '100 KB', + limit: '101 KB', }, { name: '@sentry/browser (incl. Feedback)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '43 KB', + limit: '44 KB', }, { name: '@sentry/browser (incl. sendFeedback)', @@ -233,7 +233,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '90 KB', + limit: '91 KB', }, // browser CDN bundles (non-gzipped) { @@ -290,14 +290,14 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '268 KB', + limit: '269 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '271.5 KB', + limit: '273 KB', }, // Next.js SDK (ESM) { From dadaa163228838c16932d1cdefa671b1b6646e1b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 02:18:24 -0400 Subject: [PATCH 5/6] test: add default-fallback test for partial errorMessages override Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/feedback/test/core/sendFeedback.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index 15dcf37914d0..e40f65d6a29c 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -293,6 +293,20 @@ describe('sendFeedback', () => { ).rejects.toMatch('custom forbidden text'); }); + it('falls back to default messages for codes not in errorMessages', async () => { + mockSdk(); + vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { + return Promise.resolve({ statusCode: 400 }); + }); + + // Only override ERROR_FORBIDDEN — a 400 should still use the default generic message. + await expect( + sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), + ).rejects.toMatch( + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', + ); + }); + it('handles 400 transport error', async () => { mockSdk(); vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { From fdc6ca4238c01cc88dc033b4ab8b640c8f373399 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 23 Apr 2026 23:04:39 -0400 Subject: [PATCH 6/6] feat(feedback)!: always reject sendFeedback with an Error Previously, sendFeedback's async rejection paths (timeout, 403, generic transport error) rejected with plain strings, while the sync-throw paths used Error instances. Unify on Error for all paths so consumers can rely on a consistent rejection shape. BREAKING CHANGE: sendFeedback now always rejects with an Error whose .message is the error code (ERROR_TIMEOUT, ERROR_FORBIDDEN, or ERROR_GENERIC). Previously these paths rejected with the raw string. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/feedback/src/core/sendFeedback.ts | 8 ++++---- packages/feedback/src/modal/components/Form.tsx | 2 +- packages/feedback/test/core/sendFeedback.test.ts | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index 204cf1cfa6c7..35c829ab0869 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -8,7 +8,7 @@ import type { } from '@sentry/core'; import { captureFeedback, getClient, getCurrentScope, getLocationHref } from '@sentry/core'; import { FEEDBACK_API_SOURCE } from '../constants'; -import { createFeedbackError, resolveFeedbackErrorMessage } from '../util/createFeedbackError'; +import { createFeedbackError } from '../util/createFeedbackError'; /** * Public API to send a Feedback item to Sentry @@ -47,7 +47,7 @@ export const sendFeedback: SendFeedback = ( // After 30s, we want to clear anyhow const timeout = setTimeout(() => { cleanup(); - reject(resolveFeedbackErrorMessage('ERROR_TIMEOUT', errorMessages)); + reject(createFeedbackError('ERROR_TIMEOUT', errorMessages)); }, 30_000); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { @@ -64,10 +64,10 @@ export const sendFeedback: SendFeedback = ( } if (response?.statusCode === 403) { - return reject(resolveFeedbackErrorMessage('ERROR_FORBIDDEN', errorMessages)); + return reject(createFeedbackError('ERROR_FORBIDDEN', errorMessages)); } - return reject(resolveFeedbackErrorMessage('ERROR_GENERIC', errorMessages)); + return reject(createFeedbackError('ERROR_GENERIC', errorMessages)); }); }); }; diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index ee4b06b25d88..48436b947442 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -131,7 +131,7 @@ export function Form({ onSubmitSuccess(data, eventId); } catch (error) { DEBUG_BUILD && debug.error(error); - const err = error instanceof Error ? error : new Error(String(error)); + const err = error as Error; setError(err.message); onSubmitError(err); } diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index e40f65d6a29c..d97fd2b1ea74 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -290,7 +290,7 @@ describe('sendFeedback', () => { await expect( sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), - ).rejects.toMatch('custom forbidden text'); + ).rejects.toThrow('custom forbidden text'); }); it('falls back to default messages for codes not in errorMessages', async () => { @@ -319,7 +319,7 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch( + ).rejects.toThrow( 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', ); }); @@ -336,7 +336,7 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch( + ).rejects.toThrow( 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', ); }); @@ -353,7 +353,7 @@ describe('sendFeedback', () => { email: 're@example.org', message: 'mi', }), - ).rejects.toMatch( + ).rejects.toThrow( 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.', ); }); @@ -389,7 +389,7 @@ describe('sendFeedback', () => { vi.advanceTimersByTime(30_000); - await expect(promise).rejects.toMatch('Unable to determine if Feedback was correctly sent.'); + await expect(promise).rejects.toThrow('Unable to determine if Feedback was correctly sent.'); vi.useRealTimers(); });