Close the login dropdown only on a genuine outside click

Previously any message reaching the widget closed the Sign In dropdown,
because the handler returned early only for a clickOutside payload while
closing was disabled and fell through to closing in every other case. The
embedding page posts hash, title and theme messages of its own, and
`embed.ts` installs a MutationObserver on the host page title that posts on
every mutation, so a host page whose title changes closes an open login form
and discards whatever was typed into it. Browser extensions that post into
the page have the same effect.

After this change the dropdown closes only for a clickOutside payload from
`window.parent`. Reproduced against the deployed demo: with the form open and
filled, a single `document.title` assignment on the host page removed it,
while three seconds of inactivity did not.

Resolves #2139.
This commit is contained in:
Dmitry Verkhoturov
2026-08-18 18:45:54 -05:00
committed by Umputun
parent 5b37a583ce
commit 8bcfd9e456
2 changed files with 46 additions and 3 deletions
@@ -23,9 +23,16 @@ export function useDropdown(disableClosing?: boolean) {
}
function handleMessageFromParent(evt: MessageEvent) {
// only the embedding page may close the dropdown, and only by reporting a click
// outside the widget. the parent also posts hash, title and theme messages, and
// extensions post their own, none of which should interrupt an active login
if (evt.source !== window.parent) {
return;
}
const data = parseMessage(evt);
if (disableClosing && data.clickOutside) {
if (!data.clickOutside || disableClosing) {
return;
}
@@ -22,6 +22,13 @@ describe('<Auth/>', () => {
StaticStore.config.auth_providers = defaultProviders;
});
// the embedding page posts into the widget with iframe.contentWindow.postMessage, which
// arrives with source set to the parent. jsdom leaves source unset on window.postMessage,
// so it is set here explicitly
function postFromParent(data: unknown) {
window.dispatchEvent(new MessageEvent('message', { data, source: window.parent }));
}
// TODO: separate tests of `useDropdown` mechanics with the hook
describe('useDropdown', () => {
it('should render auth with hidden dropdown', () => {
@@ -54,7 +61,7 @@ describe('<Auth/>', () => {
expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument();
});
it('should close dropdown by message from parent', async () => {
it('should close dropdown by clickOutside message from parent', async () => {
const { container } = render(<Auth />);
expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument();
@@ -62,9 +69,38 @@ describe('<Auth/>', () => {
fireEvent.click(screen.getByText('Sign In'));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
window.postMessage('{"clickOutside": true}', '*');
postFromParent({ clickOutside: true });
await waitFor(() => expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument());
});
it.each([
['title', { title: 'New title' }],
['hash', { hash: '#comment-1' }],
['theme', { theme: 'dark' }],
['an unparsable payload', 'clickOutside'],
])('should not close dropdown by %s message from parent', async (_, message) => {
const { container } = render(<Auth />);
fireEvent.click(screen.getByText('Sign In'));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
postFromParent(message);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
});
it('should not close dropdown by clickOutside message from a foreign source', async () => {
const { container } = render(<Auth />);
fireEvent.click(screen.getByText('Sign In'));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
window.dispatchEvent(new MessageEvent('message', { data: { clickOutside: true }, source: null }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
});
});
it.each([