Skip to content
Open
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,39 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect, useState } from 'react';

export default function ThemeSwitcher() {
const [feedback, setFeedback] = useState<ReturnType<typeof Sentry.getFeedback>>();
useEffect(() => {
setFeedback(Sentry.getFeedback());
}, []);

return (
<div>
<button
className="hover:bg-hover px-4 py-2 rounded-md"
type="button"
data-testid="set-light-theme"
onClick={() => feedback?.setTheme('light')}
>
Set Light Theme
</button>
<button
className="hover:bg-hover px-4 py-2 rounded-md"
type="button"
data-testid="set-dark-theme"
onClick={() => feedback?.setTheme('dark')}
>
Set Dark Theme
</button>
<button
className="hover:bg-hover px-4 py-2 rounded-md"
type="button"
data-testid="set-system-theme"
onClick={() => feedback?.setTheme('system')}
>
Set System Theme
</button>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -63,6 +64,12 @@ export default function Home() {
<TranslatedFeedbackForm />
</fieldset>
</li>
<li>
<fieldset className="border-1 border-gray-300 rounded-md p-2" data-testid="theme-switcher-section">
<legend>Theme Switcher</legend>
<ThemeSwitcher />
</fieldset>
</li>
</ul>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion packages/feedback/src/core/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const buildFeedbackIntegration = ({
optionOverrides?: OverrideFeedbackConfiguration,
): Promise<ReturnType<FeedbackModalIntegration['createDialog']>>;
createWidget(optionOverrides?: OverrideFeedbackConfiguration): ActorComponent;
setTheme(colorScheme: 'light' | 'dark' | 'system'): void;
remove(): void;
}
> => {
Expand Down Expand Up @@ -172,6 +173,7 @@ export const buildFeedbackIntegration = ({
};

let _shadow: ShadowRoot | null = null;
let _mainStyle: HTMLStyleElement | null = null;
let _subscriptions: Unsubscribe[] = [];

/**
Expand All @@ -184,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;
};
Expand Down Expand Up @@ -348,13 +351,30 @@ 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 newStyle = createMainStyles(_options);
if (_mainStyle) {
_shadow.replaceChild(newStyle, _mainStyle);
} else {
_shadow.prepend(newStyle);
}
_mainStyle = newStyle;
}
},

/**
* Removes the Feedback integration (including host, shadow DOM, and all widgets)
*/
remove(): void {
if (_shadow) {
_shadow.parentElement?.remove();
_shadow = null;
_mainStyle = null;
}
// Remove any lingering subscriptions
_subscriptions.forEach(sub => sub());
Expand Down
91 changes: 91 additions & 0 deletions packages/feedback/test/core/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading