fix: keep empty plugin frames rendered so they can report a height, and out of the Tab order

This commit is contained in:
Linus Rath
2026-11-03 14:38:21 +01:00
parent ba45169aae
commit 2aab469816
2 changed files with 49 additions and 5 deletions
@@ -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(<PluginIframeSlot pluginId="p1" slot={slot} extraProps={{}} />);
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(<PluginIframeSlot pluginId="p1" slot="composer-sidebar-right" extraProps={{}} fill />);
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']);
+6 -3
View File
@@ -190,9 +190,12 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, min
<div
ref={wrapperRef}
style={inline
? { display: 'inline-block', verticalAlign: 'middle', height, width: width ?? 0, maxWidth: '100%', overflow: 'hidden', visibility: height > 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}
/>