diff --git a/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx index eb57628f3..37cc6919a 100644 --- a/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx +++ b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import type { InstalledPlugin } from '@/lib/plugin-types'; const evaluateShouldShow = vi.fn(async () => true); @@ -12,7 +12,11 @@ vi.mock('@/lib/plugin-sandbox/registry', () => ({ code: '', tier: 'untrusted', background: { evaluateShouldShow }, - slotOffers: [{ name: 'email-banner', hasShouldShow, order: 100 }, { name: 'composer-sidebar-right', hasShouldShow: false, order: 100, fill: true }], + slotOffers: [ + { name: 'email-banner', hasShouldShow, order: 100 }, + { name: 'composer-sidebar-right', hasShouldShow: false, order: 100, fill: true }, + { name: 'toolbar-actions', hasShouldShow: false, order: 100 }, + ], }), })); @@ -87,6 +91,43 @@ describe('PluginIframeSlot', () => { }); }); +describe('PluginIframeSlot with nothing to show yet', () => { + type SlotOptions = { onResize: (height: number, width?: number) => void }; + const reportSize = (height: number, width?: number) => act(() => { + ((createSlotInstance.mock.calls[0] as unknown[])[0] as SlotOptions).onResize(height, width); + }); + + it.each([ + ['block', 'email-banner', ['ui:email-banner']], + ['inline', 'toolbar-actions', ['ui:toolbar']], + ] as const)('keeps a %s frame rendered and out of the Tab order until it reports a height', async (_kind, slot, perms) => { + activePlugin = plugin([...perms]); + const { container } = render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const wrapper = container.querySelector('[data-plugin-iframe-slot]') as HTMLElement; + + expect(wrapper).toHaveAttribute('inert'); + // Hidden, an isolated frame stops rendering and can never report the height that would unhide it. + expect(wrapper.style.visibility).toBe(''); + + reportSize(32, 100); + expect(wrapper).not.toHaveAttribute('inert'); + expect(wrapper.style.height).toBe('32px'); + + // Its content went away: nothing to land on again, still rendered. + reportSize(0, 100); + expect(wrapper).toHaveAttribute('inert'); + expect(wrapper.style.visibility).toBe(''); + }); + + it('leaves a frame that fills its panel alone: its height never comes from the frame', async () => { + activePlugin = plugin(['ui:composer-sidebar']); + const { container } = render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + expect(container.querySelector('[data-plugin-iframe-slot]')).not.toHaveAttribute('inert'); + }); +}); + describe('PluginIframeSlot re-renders', () => { it('does not ask shouldShow again or push props when a re-render brings equal props', async () => { activePlugin = plugin(['ui:email-banner', 'email:read']); diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index 761b18d12..49faf6ecb 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -190,9 +190,12 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, min
0 ? undefined : 'hidden' } - // A frame with nothing to show must not be a stop for Tab, where the focus would vanish. - : fill ? { flex: '1 1 0%', minHeight: minFillHeight ?? 0 } : { height, minHeight: height, visibility: height > 0 ? undefined : 'hidden' }} + ? { display: 'inline-block', verticalAlign: 'middle', height, width: width ?? 0, maxWidth: '100%', overflow: 'hidden' } + : fill ? { flex: '1 1 0%', minHeight: minFillHeight ?? 0 } : { height, minHeight: height }} + // A frame with nothing to show must not be a stop for Tab, where the + // focus would vanish. Not visibility: hidden, because the browser stops + // rendering an isolated frame then, and only its rendering reports a height. + inert={!fill && height === 0} data-plugin-iframe-slot={`${pluginId}:${slot}`} data-plugin-iframe-fill={fill || undefined} />