fix(web): add alert when pdf export popup is blocked (#664)

Fixes PDF export feedback when popup blockers prevent opening the export preview.
This commit is contained in:
Pratik Rai 2026-05-07 16:09:42 +05:30 committed by GitHub
parent 2bbd677ea2
commit 555dbebfe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 75 additions and 27 deletions

View file

@ -243,10 +243,16 @@ export function App() {
setTemplates(list);
}, []);
const handleConfigSave = useCallback((next: AppConfig) => {
// Saving from any settings dialog (welcome or regular) counts as
// having completed onboarding — the user has actively chosen a
// configuration, so future page loads can skip the auto-popup.
const handleConfigSave = useCallback(async (next: AppConfig, closeModal: boolean = true) => {
// Only sync Composio key to the daemon when it actually changed,
// so unrelated saves (theme, model, etc.) are never blocked.
const composioChanged =
next.composio?.apiKey !== config.composio?.apiKey ||
next.composio?.apiKeyConfigured !== config.composio?.apiKeyConfigured;
if (composioChanged) {
const ok = await syncComposioConfigToDaemon(next.composio);
if (!ok) return { success: false };
}
const withOnboarding: AppConfig = {
...next,
composio: normalizeSavedComposioConfig(next.composio),
@ -257,12 +263,10 @@ export function App() {
force: true,
});
void syncConfigToDaemon(withOnboarding);
// Keep the Composio secret out of localStorage, but send the raw pending
// edit to the daemon before it is normalized away for local persistence.
void syncComposioConfigToDaemon(next.composio);
setConfig(withOnboarding);
setSettingsOpen(false);
}, []);
if (closeModal) setSettingsOpen(false);
return { success: true };
}, [config]);
const handleModeChange = useCallback(
(mode: AppConfig['mode']) => {

View file

@ -62,7 +62,7 @@ interface Props {
appVersionInfo: AppVersionInfo | null;
welcome?: boolean;
initialSection?: SettingsSection;
onSave: (cfg: AppConfig) => void;
onSave: (cfg: AppConfig, closeModal?: boolean) => Promise<{ success: boolean }> | void;
onClose: () => void;
onRefreshAgents: (
options?: AgentRefreshOptions,
@ -1686,7 +1686,7 @@ export function SettingsDialog({
type="button"
className="primary"
disabled={!canSave}
onClick={() => onSave(cfg)}
onClick={() => onSave(cfg, activeSection !== 'composio')}
>
{welcome ? t('settings.getStarted') : t('common.save')}
</button>

View file

@ -236,14 +236,31 @@ export function exportAsPdf(
}
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank', sandboxedPreview ? 'noopener,noreferrer' : undefined);
// Open an empty tab synchronously (without noopener) to reliably detect popup blocking.
// Since window.open with 'noopener' returns null on success by specification,
// this approach allows us to distinguish between a successful export and a blocked popup.
const win = window.open('', '_blank');
if (!win) {
// Popup blocked — at least the tab navigation may have happened above.
// Nothing else we can do without a fresh user gesture.
if (typeof alert !== 'undefined') {
alert('Popup blocked! Please allow popups for this site to export as PDF.');
}
URL.revokeObjectURL(url); // Prevent memory leaks on early exit
return;
}
// Revoke later — the loaded document keeps a reference until the tab
// closes; revoking the URL string only removes the lookup name.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
if (sandboxedPreview) {
try {
// Disassociate the opener reference to preserve sandboxing/noopener behavior
win.opener = null;
} catch (e) {
// Guard against potential context environment restrictions
}
}
// Navigate the verified window to the generated Blob URL
win.location.href = url;
}
function injectPrintScript(doc: string, title: string): string {

View file

@ -294,19 +294,20 @@ export async function fetchComposioConfigFromDaemon(): Promise<AppConfig['compos
export async function syncComposioConfigToDaemon(
config: AppConfig['composio'] | undefined,
): Promise<void> {
): Promise<boolean> {
const apiKey = config?.apiKey ?? '';
const payload = {
...(apiKey.trim() || !config?.apiKeyConfigured ? { apiKey } : {}),
};
try {
await fetch('/api/connectors/composio/config', {
const response = await fetch('/api/connectors/composio/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
return response.ok;
} catch {
// Daemon offline; localStorage keeps the user's copy for the next save.
return false;
}
}

View file

@ -145,24 +145,30 @@ describe('exportAsMd', () => {
describe('sandboxed preview Blob exports', () => {
let capturedBlob: Blob | undefined;
let openedFeatures: string | undefined;
let mockWin: { opener: unknown; location: { href: string } };
let openCalls: string[][];
beforeEach(() => {
capturedBlob = undefined;
openedFeatures = undefined;
openCalls = [];
mockWin = { opener: {}, location: { href: '' } };
vi.stubGlobal('URL', {
createObjectURL: (blob: Blob) => {
capturedBlob = blob;
return 'blob:test';
},
revokeObjectURL: () => {},
revokeObjectURL: vi.fn(),
});
vi.stubGlobal('window', {
open: (_url: string, _target: string, features?: string) => {
openCalls.push([_url, _target]);
openedFeatures = features;
return null;
return mockWin;
},
addEventListener: () => {},
});
vi.stubGlobal('alert', vi.fn());
});
afterEach(() => {
@ -206,10 +212,12 @@ describe('sandboxed preview Blob exports', () => {
expect(wrapper).not.toContain('allow-same-origin');
});
it('uses a sandboxed noopener Blob wrapper by default for PDF exports', async () => {
it('uses a sandboxed Blob wrapper with synchronous popup detection for PDF exports', async () => {
exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');
expect(openedFeatures).toBe('noopener,noreferrer');
expect(openCalls).toEqual([['', '_blank']]);
expect(mockWin.opener).toBeNull();
expect(mockWin.location.href).toBe('blob:test');
expect(capturedBlob).toBeDefined();
const wrapper = await capturedBlob!.text();
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
@ -221,11 +229,12 @@ describe('sandboxed preview Blob exports', () => {
it('preserves deck print handling inside sandboxed PDF exports', async () => {
exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });
expect(openedFeatures).toBe('noopener,noreferrer');
expect(openCalls).toEqual([['', '_blank']]);
expect(mockWin.opener).toBeNull();
expect(mockWin.location.href).toBe('blob:test');
expect(capturedBlob).toBeDefined();
const wrapper = await capturedBlob!.text();
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
expect(wrapper).not.toContain('allow-same-origin');
expect(wrapper).toContain('data-deck-print=&quot;injected&quot;');
expect(wrapper).toContain('page-break-after: always;');
});
@ -235,10 +244,27 @@ describe('sandboxed preview Blob exports', () => {
sandboxedPreview: false,
});
expect(openedFeatures).toBeUndefined();
expect(openCalls).toEqual([['', '_blank']]);
expect(mockWin.opener).toEqual({});
expect(mockWin.location.href).toBe('blob:test');
expect(capturedBlob).toBeDefined();
const doc = await capturedBlob!.text();
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
expect(doc).toContain('<main>Trusted local document</main>');
});
it('shows an alert and revokes the blob URL when the popup is blocked', async () => {
vi.stubGlobal('window', {
...window,
open: () => null,
});
const revokeSpy = URL.revokeObjectURL as ReturnType<typeof vi.fn>;
revokeSpy.mockClear();
exportAsPdf('<p>test</p>', 'Blocked');
expect(alert).toHaveBeenCalledWith('Popup blocked! Please allow popups for this site to export as PDF.');
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
});
});