From 8bcfd9e456665646d4bb1d9bb8123c048ee7a6b3 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Tue, 18 Aug 2026 20:25:31 +0100 Subject: [PATCH] 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. --- .../app/components/auth/auth.hooks.ts | 9 ++++- .../app/components/auth/auth.spec.tsx | 40 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/frontend/apps/remark42/app/components/auth/auth.hooks.ts b/frontend/apps/remark42/app/components/auth/auth.hooks.ts index 7c7465b6..64271ea1 100644 --- a/frontend/apps/remark42/app/components/auth/auth.hooks.ts +++ b/frontend/apps/remark42/app/components/auth/auth.hooks.ts @@ -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; } diff --git a/frontend/apps/remark42/app/components/auth/auth.spec.tsx b/frontend/apps/remark42/app/components/auth/auth.spec.tsx index 4a8966e5..f1737189 100644 --- a/frontend/apps/remark42/app/components/auth/auth.spec.tsx +++ b/frontend/apps/remark42/app/components/auth/auth.spec.tsx @@ -22,6 +22,13 @@ describe('', () => { 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('', () => { 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(); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); @@ -62,9 +69,38 @@ describe('', () => { 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(); + + 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(); + + 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([