From f0810c2a9e60cffe0c3db539abf529487484f9fa Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 19 Feb 2026 16:27:37 +0100 Subject: [PATCH 1/4] add toggle --- packages/feedback/src/core/integration.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 9ce0feac921f..020df9e5acff 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -348,6 +348,22 @@ export const buildFeedbackIntegration = ({ return _loadAndRenderDialog(mergeOptions(_options, optionOverrides)); }, + /** + * Updates the color scheme of the feedback widget at runtime. + */ + setTheme(colorScheme: 'light' | 'dark' | 'system'): void { + _options.colorScheme = colorScheme; + if (_shadow) { + const existingStyle = _shadow.querySelector('style'); + const newStyle = createMainStyles(_options); + if (existingStyle) { + _shadow.replaceChild(newStyle, existingStyle); + } else { + _shadow.prepend(newStyle); + } + } + }, + /** * Removes the Feedback integration (including host, shadow DOM, and all widgets) */ From be6f357e5abe7f392e3b17cad494bd9973a1639f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 19 Feb 2026 16:27:41 +0100 Subject: [PATCH 2/4] tests --- .../app/examples/themeSwitcher.tsx | 39 ++++++++ .../nextjs-16-userfeedback/app/page.tsx | 7 ++ .../tests/feedback.test.ts | 37 ++++++++ .../feedback/test/core/integration.test.ts | 91 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/examples/themeSwitcher.tsx create mode 100644 packages/feedback/test/core/integration.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/examples/themeSwitcher.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/examples/themeSwitcher.tsx new file mode 100644 index 000000000000..c9f96fe8ea32 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/examples/themeSwitcher.tsx @@ -0,0 +1,39 @@ +'use client'; +import * as Sentry from '@sentry/nextjs'; +import { useEffect, useState } from 'react'; + +export default function ThemeSwitcher() { + const [feedback, setFeedback] = useState>(); + useEffect(() => { + setFeedback(Sentry.getFeedback()); + }, []); + + return ( +
+ + + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/page.tsx index cd58d35ab4fa..8076bb8322e6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/page.tsx @@ -6,6 +6,7 @@ import MyFeedbackForm from './examples/myFeedbackForm'; import CrashReportButton from './examples/crashReportButton'; import ThumbsUpDownButtons from './examples/thumbsUpDownButtons'; import TranslatedFeedbackForm from './examples/translatedFeedbackForm'; +import ThemeSwitcher from './examples/themeSwitcher'; export default function Home() { return ( @@ -63,6 +64,12 @@ export default function Home() { +
  • +
    + Theme Switcher + +
    +
  • ); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/tests/feedback.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/tests/feedback.test.ts index 0dc31e40ba61..950adaba10ba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/tests/feedback.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/tests/feedback.test.ts @@ -198,6 +198,43 @@ test('feedback dialog can be cancelled', async ({ page }) => { await expect(feedbackDialog).not.toBeVisible({ timeout: 5000 }); }); +test('setTheme changes the feedback widget color scheme', async ({ page }) => { + await page.goto('/'); + + // First open a widget to force shadow DOM creation + await page.getByTestId('toggle-feedback-button').click(); + await expect(page.locator('.widget__actor')).toBeVisible({ timeout: 5000 }); + + // Switch to dark theme and verify shadow DOM style reflects it + await page.getByTestId('set-dark-theme').click(); + const hasDarkScheme = await page.evaluate(() => { + const host = document.querySelector('#sentry-feedback'); + const style = host?.shadowRoot?.querySelector('style'); + return style?.textContent?.includes('color-scheme: only dark') ?? false; + }); + expect(hasDarkScheme).toBe(true); + + // Switch to light theme and verify + await page.getByTestId('set-light-theme').click(); + const hasLightScheme = await page.evaluate(() => { + const host = document.querySelector('#sentry-feedback'); + const style = host?.shadowRoot?.querySelector('style'); + return style?.textContent?.includes('color-scheme: only light') ?? false; + }); + expect(hasLightScheme).toBe(true); + + // Switch to system and verify no forced light/dark color-scheme at host level + await page.getByTestId('set-system-theme').click(); + const hasSystemScheme = await page.evaluate(() => { + const host = document.querySelector('#sentry-feedback'); + const style = host?.shadowRoot?.querySelector('style'); + const content = style?.textContent ?? ''; + // System mode uses a media query for dark theme, not a forced color-scheme + return !content.includes('color-scheme: only light') && content.includes('prefers-color-scheme'); + }); + expect(hasSystemScheme).toBe(true); +}); + test('crash report button triggers error for user feedback modal', async ({ page }) => { const errorPromise = waitForEnvelopeItem('nextjs-16-userfeedback', envelopeItem => { const [envelopeItemHeader, envelopeItemBody] = envelopeItem; diff --git a/packages/feedback/test/core/integration.test.ts b/packages/feedback/test/core/integration.test.ts new file mode 100644 index 000000000000..8a93f86adab2 --- /dev/null +++ b/packages/feedback/test/core/integration.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import { getCurrentScope } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildFeedbackIntegration } from '../../src/core/integration'; +import { mockSdk } from './mockSdk'; + +describe('setTheme', () => { + beforeEach(() => { + getCurrentScope().setClient(undefined); + document.body.innerHTML = ''; + }); + + it('updates colorScheme and replaces the stylesheet in the shadow DOM', () => { + const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() }); + const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false }); + mockSdk({ sentryOptions: { integrations: [integration] } }); + + // Force shadow DOM creation + integration.createWidget(); + + const host = document.querySelector('#sentry-feedback') as HTMLElement; + const shadow = host?.shadowRoot; + expect(shadow).toBeTruthy(); + + // Verify initial light scheme + const initialStyle = shadow?.querySelector('style'); + expect(initialStyle?.textContent).toContain('color-scheme: only light'); + + // Switch to dark + integration.setTheme('dark'); + + const updatedStyle = shadow?.querySelector('style'); + expect(updatedStyle?.textContent).toContain('color-scheme: only dark'); + }); + + it("setTheme('system') sets system mode", () => { + const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() }); + const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false }); + mockSdk({ sentryOptions: { integrations: [integration] } }); + + integration.createWidget(); + + integration.setTheme('system'); + + const host = document.querySelector('#sentry-feedback') as HTMLElement; + const shadow = host?.shadowRoot; + const style = shadow?.querySelector('style'); + // System mode uses a media query for dark, not a forced color-scheme at the :host level + expect(style?.textContent).toContain('prefers-color-scheme'); + // Should not force light color scheme + expect(style?.textContent).not.toContain('color-scheme: only light'); + }); + + it('does not throw when setTheme is called before shadow DOM is created', () => { + const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() }); + const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false }); + mockSdk({ sentryOptions: { integrations: [integration] } }); + + // Call setTheme before any widget is created + expect(() => integration.setTheme('dark')).not.toThrow(); + + // Now create a widget — it should pick up the updated colorScheme + integration.createWidget(); + + const host = document.querySelector('#sentry-feedback') as HTMLElement; + const shadow = host?.shadowRoot; + const style = shadow?.querySelector('style'); + expect(style?.textContent).toContain('color-scheme: only dark'); + }); + + it('replaces (not accumulates) style elements on multiple setTheme calls', () => { + const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() }); + const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false }); + mockSdk({ sentryOptions: { integrations: [integration] } }); + + integration.createWidget(); + + const host = document.querySelector('#sentry-feedback') as HTMLElement; + const shadow = host?.shadowRoot; + const countAfterCreate = shadow?.querySelectorAll('style').length ?? 0; + + // Multiple setTheme calls should not accumulate additional style elements + integration.setTheme('dark'); + integration.setTheme('light'); + integration.setTheme('system'); + + expect(shadow?.querySelectorAll('style').length).toBe(countAfterCreate); + }); +}); From fe3c1da27ebeabea63731f4d42abcb5a04f33e8f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 19 Feb 2026 18:26:08 +0100 Subject: [PATCH 3/4] adapt type --- packages/feedback/src/core/integration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 020df9e5acff..4e55c4e1b002 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -72,6 +72,7 @@ export const buildFeedbackIntegration = ({ optionOverrides?: OverrideFeedbackConfiguration, ): Promise>; createWidget(optionOverrides?: OverrideFeedbackConfiguration): ActorComponent; + setTheme(colorScheme: 'light' | 'dark' | 'system'): void; remove(): void; } > => { From 489785db3e16383fbcc14fe5594f969142997ee1 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 20 Feb 2026 13:25:46 +0100 Subject: [PATCH 4/4] set on main style --- packages/feedback/src/core/integration.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 4e55c4e1b002..1dc418ed131f 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -173,6 +173,7 @@ export const buildFeedbackIntegration = ({ }; let _shadow: ShadowRoot | null = null; + let _mainStyle: HTMLStyleElement | null = null; let _subscriptions: Unsubscribe[] = []; /** @@ -185,7 +186,8 @@ export const buildFeedbackIntegration = ({ DOCUMENT.body.appendChild(host); _shadow = host.attachShadow({ mode: 'open' }); - _shadow.appendChild(createMainStyles(options)); + _mainStyle = createMainStyles(options); + _shadow.appendChild(_mainStyle); } return _shadow; }; @@ -355,13 +357,13 @@ export const buildFeedbackIntegration = ({ setTheme(colorScheme: 'light' | 'dark' | 'system'): void { _options.colorScheme = colorScheme; if (_shadow) { - const existingStyle = _shadow.querySelector('style'); const newStyle = createMainStyles(_options); - if (existingStyle) { - _shadow.replaceChild(newStyle, existingStyle); + if (_mainStyle) { + _shadow.replaceChild(newStyle, _mainStyle); } else { _shadow.prepend(newStyle); } + _mainStyle = newStyle; } }, @@ -372,6 +374,7 @@ export const buildFeedbackIntegration = ({ if (_shadow) { _shadow.parentElement?.remove(); _shadow = null; + _mainStyle = null; } // Remove any lingering subscriptions _subscriptions.forEach(sub => sub());