Skip to content

Commit 10e7421

Browse files
authored
feat(feedback): Add setTheme() to dynamically update feedback widget color scheme (#19430)
Adds a `setTheme(colorScheme: 'light' | 'dark' | 'system')` method to the feedback integration, allowing applications to update the widget's color scheme at runtime without re-initializing the integration. closes #19257
1 parent 02b1727 commit 10e7421

File tree

5 files changed

+195
-1
lines changed

5 files changed

+195
-1
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
import * as Sentry from '@sentry/nextjs';
3+
import { useEffect, useState } from 'react';
4+
5+
export default function ThemeSwitcher() {
6+
const [feedback, setFeedback] = useState<ReturnType<typeof Sentry.getFeedback>>();
7+
useEffect(() => {
8+
setFeedback(Sentry.getFeedback());
9+
}, []);
10+
11+
return (
12+
<div>
13+
<button
14+
className="hover:bg-hover px-4 py-2 rounded-md"
15+
type="button"
16+
data-testid="set-light-theme"
17+
onClick={() => feedback?.setTheme('light')}
18+
>
19+
Set Light Theme
20+
</button>
21+
<button
22+
className="hover:bg-hover px-4 py-2 rounded-md"
23+
type="button"
24+
data-testid="set-dark-theme"
25+
onClick={() => feedback?.setTheme('dark')}
26+
>
27+
Set Dark Theme
28+
</button>
29+
<button
30+
className="hover:bg-hover px-4 py-2 rounded-md"
31+
type="button"
32+
data-testid="set-system-theme"
33+
onClick={() => feedback?.setTheme('system')}
34+
>
35+
Set System Theme
36+
</button>
37+
</div>
38+
);
39+
}

dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/app/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import MyFeedbackForm from './examples/myFeedbackForm';
66
import CrashReportButton from './examples/crashReportButton';
77
import ThumbsUpDownButtons from './examples/thumbsUpDownButtons';
88
import TranslatedFeedbackForm from './examples/translatedFeedbackForm';
9+
import ThemeSwitcher from './examples/themeSwitcher';
910

1011
export default function Home() {
1112
return (
@@ -63,6 +64,12 @@ export default function Home() {
6364
<TranslatedFeedbackForm />
6465
</fieldset>
6566
</li>
67+
<li>
68+
<fieldset className="border-1 border-gray-300 rounded-md p-2" data-testid="theme-switcher-section">
69+
<legend>Theme Switcher</legend>
70+
<ThemeSwitcher />
71+
</fieldset>
72+
</li>
6673
</ul>
6774
</div>
6875
);

dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/tests/feedback.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,43 @@ test('feedback dialog can be cancelled', async ({ page }) => {
198198
await expect(feedbackDialog).not.toBeVisible({ timeout: 5000 });
199199
});
200200

201+
test('setTheme changes the feedback widget color scheme', async ({ page }) => {
202+
await page.goto('/');
203+
204+
// First open a widget to force shadow DOM creation
205+
await page.getByTestId('toggle-feedback-button').click();
206+
await expect(page.locator('.widget__actor')).toBeVisible({ timeout: 5000 });
207+
208+
// Switch to dark theme and verify shadow DOM style reflects it
209+
await page.getByTestId('set-dark-theme').click();
210+
const hasDarkScheme = await page.evaluate(() => {
211+
const host = document.querySelector('#sentry-feedback');
212+
const style = host?.shadowRoot?.querySelector('style');
213+
return style?.textContent?.includes('color-scheme: only dark') ?? false;
214+
});
215+
expect(hasDarkScheme).toBe(true);
216+
217+
// Switch to light theme and verify
218+
await page.getByTestId('set-light-theme').click();
219+
const hasLightScheme = await page.evaluate(() => {
220+
const host = document.querySelector('#sentry-feedback');
221+
const style = host?.shadowRoot?.querySelector('style');
222+
return style?.textContent?.includes('color-scheme: only light') ?? false;
223+
});
224+
expect(hasLightScheme).toBe(true);
225+
226+
// Switch to system and verify no forced light/dark color-scheme at host level
227+
await page.getByTestId('set-system-theme').click();
228+
const hasSystemScheme = await page.evaluate(() => {
229+
const host = document.querySelector('#sentry-feedback');
230+
const style = host?.shadowRoot?.querySelector('style');
231+
const content = style?.textContent ?? '';
232+
// System mode uses a media query for dark theme, not a forced color-scheme
233+
return !content.includes('color-scheme: only light') && content.includes('prefers-color-scheme');
234+
});
235+
expect(hasSystemScheme).toBe(true);
236+
});
237+
201238
test('crash report button triggers error for user feedback modal', async ({ page }) => {
202239
const errorPromise = waitForEnvelopeItem('nextjs-16-userfeedback', envelopeItem => {
203240
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;

packages/feedback/src/core/integration.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const buildFeedbackIntegration = ({
7272
optionOverrides?: OverrideFeedbackConfiguration,
7373
): Promise<ReturnType<FeedbackModalIntegration['createDialog']>>;
7474
createWidget(optionOverrides?: OverrideFeedbackConfiguration): ActorComponent;
75+
setTheme(colorScheme: 'light' | 'dark' | 'system'): void;
7576
remove(): void;
7677
}
7778
> => {
@@ -172,6 +173,7 @@ export const buildFeedbackIntegration = ({
172173
};
173174

174175
let _shadow: ShadowRoot | null = null;
176+
let _mainStyle: HTMLStyleElement | null = null;
175177
let _subscriptions: Unsubscribe[] = [];
176178

177179
/**
@@ -184,7 +186,8 @@ export const buildFeedbackIntegration = ({
184186
DOCUMENT.body.appendChild(host);
185187

186188
_shadow = host.attachShadow({ mode: 'open' });
187-
_shadow.appendChild(createMainStyles(options));
189+
_mainStyle = createMainStyles(options);
190+
_shadow.appendChild(_mainStyle);
188191
}
189192
return _shadow;
190193
};
@@ -348,13 +351,30 @@ export const buildFeedbackIntegration = ({
348351
return _loadAndRenderDialog(mergeOptions(_options, optionOverrides));
349352
},
350353

354+
/**
355+
* Updates the color scheme of the feedback widget at runtime.
356+
*/
357+
setTheme(colorScheme: 'light' | 'dark' | 'system'): void {
358+
_options.colorScheme = colorScheme;
359+
if (_shadow) {
360+
const newStyle = createMainStyles(_options);
361+
if (_mainStyle) {
362+
_shadow.replaceChild(newStyle, _mainStyle);
363+
} else {
364+
_shadow.prepend(newStyle);
365+
}
366+
_mainStyle = newStyle;
367+
}
368+
},
369+
351370
/**
352371
* Removes the Feedback integration (including host, shadow DOM, and all widgets)
353372
*/
354373
remove(): void {
355374
if (_shadow) {
356375
_shadow.parentElement?.remove();
357376
_shadow = null;
377+
_mainStyle = null;
358378
}
359379
// Remove any lingering subscriptions
360380
_subscriptions.forEach(sub => sub());
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { getCurrentScope } from '@sentry/core';
5+
import { beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { buildFeedbackIntegration } from '../../src/core/integration';
7+
import { mockSdk } from './mockSdk';
8+
9+
describe('setTheme', () => {
10+
beforeEach(() => {
11+
getCurrentScope().setClient(undefined);
12+
document.body.innerHTML = '';
13+
});
14+
15+
it('updates colorScheme and replaces the stylesheet in the shadow DOM', () => {
16+
const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() });
17+
const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false });
18+
mockSdk({ sentryOptions: { integrations: [integration] } });
19+
20+
// Force shadow DOM creation
21+
integration.createWidget();
22+
23+
const host = document.querySelector('#sentry-feedback') as HTMLElement;
24+
const shadow = host?.shadowRoot;
25+
expect(shadow).toBeTruthy();
26+
27+
// Verify initial light scheme
28+
const initialStyle = shadow?.querySelector('style');
29+
expect(initialStyle?.textContent).toContain('color-scheme: only light');
30+
31+
// Switch to dark
32+
integration.setTheme('dark');
33+
34+
const updatedStyle = shadow?.querySelector('style');
35+
expect(updatedStyle?.textContent).toContain('color-scheme: only dark');
36+
});
37+
38+
it("setTheme('system') sets system mode", () => {
39+
const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() });
40+
const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false });
41+
mockSdk({ sentryOptions: { integrations: [integration] } });
42+
43+
integration.createWidget();
44+
45+
integration.setTheme('system');
46+
47+
const host = document.querySelector('#sentry-feedback') as HTMLElement;
48+
const shadow = host?.shadowRoot;
49+
const style = shadow?.querySelector('style');
50+
// System mode uses a media query for dark, not a forced color-scheme at the :host level
51+
expect(style?.textContent).toContain('prefers-color-scheme');
52+
// Should not force light color scheme
53+
expect(style?.textContent).not.toContain('color-scheme: only light');
54+
});
55+
56+
it('does not throw when setTheme is called before shadow DOM is created', () => {
57+
const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() });
58+
const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false });
59+
mockSdk({ sentryOptions: { integrations: [integration] } });
60+
61+
// Call setTheme before any widget is created
62+
expect(() => integration.setTheme('dark')).not.toThrow();
63+
64+
// Now create a widget — it should pick up the updated colorScheme
65+
integration.createWidget();
66+
67+
const host = document.querySelector('#sentry-feedback') as HTMLElement;
68+
const shadow = host?.shadowRoot;
69+
const style = shadow?.querySelector('style');
70+
expect(style?.textContent).toContain('color-scheme: only dark');
71+
});
72+
73+
it('replaces (not accumulates) style elements on multiple setTheme calls', () => {
74+
const feedbackIntegration = buildFeedbackIntegration({ lazyLoadIntegration: vi.fn() });
75+
const integration = feedbackIntegration({ colorScheme: 'light', autoInject: false });
76+
mockSdk({ sentryOptions: { integrations: [integration] } });
77+
78+
integration.createWidget();
79+
80+
const host = document.querySelector('#sentry-feedback') as HTMLElement;
81+
const shadow = host?.shadowRoot;
82+
const countAfterCreate = shadow?.querySelectorAll('style').length ?? 0;
83+
84+
// Multiple setTheme calls should not accumulate additional style elements
85+
integration.setTheme('dark');
86+
integration.setTheme('light');
87+
integration.setTheme('system');
88+
89+
expect(shadow?.querySelectorAll('style').length).toBe(countAfterCreate);
90+
});
91+
});

0 commit comments

Comments
 (0)