From a9bffd851181ccd3fcd886a602aac2054b674051 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 27 Oct 2026 09:57:09 -0400 Subject: [PATCH] fix(mosaic): stop the trigger badge flickering with an inline renderTriggerBadge --- .changeset/mosaic-trigger-badge-flicker.md | 5 ++ .../organization-switcher.feature.test.tsx | 18 ++--- .../switcher/switcher-trigger.controller.ts | 23 +++---- .../__tests__/user-button.feature.test.tsx | 68 +++++++++++++------ 4 files changed, 70 insertions(+), 44 deletions(-) create mode 100644 .changeset/mosaic-trigger-badge-flicker.md diff --git a/.changeset/mosaic-trigger-badge-flicker.md b/.changeset/mosaic-trigger-badge-flicker.md new file mode 100644 index 00000000000..bda8d737e45 --- /dev/null +++ b/.changeset/mosaic-trigger-badge-flicker.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Fix the `UserButton` and `OrganizationSwitcher` trigger badge flickering when the popup opens with an inline `renderTriggerBadge` function. The badge is now only requested again when the displayed user, organization or session changes, and each request uses the latest `renderTriggerBadge` you pass. diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 1a752b5ba21..7ec67582b09 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -314,16 +314,16 @@ describe('OrganizationSwitcher custom trigger badges', () => { }); it('hides the badge for null and keeps the organization name', async () => { - const { rerender } = await renderSwitcher({ - renderTriggerBadge: () => Promise.resolve({ id: 'pro', label: 'Pro plan' }), - }); + const renderTriggerBadge = vi.fn(({ id }: { id: string }) => + Promise.resolve(id === 'org_1' ? { id: 'pro', label: 'Pro plan' } : null), + ); + const { clerk } = await renderSwitcher({ renderTriggerBadge }); await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument()); - const renderTriggerBadge = vi.fn(() => Promise.resolve(null)); - rerender(tree({ renderTriggerBadge })); - await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalled()); + await act(() => clerk.setActive({ organization: 'org_9' })); + await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' })); expect(trigger()).not.toHaveTextContent('Pro plan'); - expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); - expect(trigger()).toHaveAccessibleName('Open organization menu for Acme'); + expect(within(trigger()).getByText('Other')).toBeInTheDocument(); + expect(trigger()).toHaveAccessibleName('Open organization menu for Other'); }); }); diff --git a/packages/mosaic/src/features/switcher/switcher-trigger.controller.ts b/packages/mosaic/src/features/switcher/switcher-trigger.controller.ts index a9f65d86003..b2192f6ff96 100644 --- a/packages/mosaic/src/features/switcher/switcher-trigger.controller.ts +++ b/packages/mosaic/src/features/switcher/switcher-trigger.controller.ts @@ -16,20 +16,17 @@ export function useSwitcherTriggerController({ renderTriggerBadge: SwitcherTriggerBadge; renderTriggerLabel: boolean; }) { - const request = useMemo(() => { - const shouldRequestBadge = renderTriggerLabel && kind !== 'none' && id && typeof renderTriggerBadge === 'function'; + const shouldRequestBadge = renderTriggerLabel && typeof renderTriggerBadge === 'function'; + const request = useMemo( + () => (kind !== 'none' && id ? { options: { kind, id }, sessionId } : undefined), + [kind, id, sessionId], + ); - if (!shouldRequestBadge) { - return undefined; - } - - return { options: { kind, id }, resolve: renderTriggerBadge, sessionId }; - }, [kind, id, renderTriggerBadge, renderTriggerLabel, sessionId]); - - const result = useDebouncedAsync(request, async value => value?.resolve(value.options), { - delayMs: 0, - enabled: request !== undefined, - }); + const result = useDebouncedAsync( + request, + async value => (value && typeof renderTriggerBadge === 'function' ? renderTriggerBadge(value.options) : undefined), + { delayMs: 0, enabled: shouldRequestBadge && request !== undefined }, + ); return { badge: result.isPending ? undefined : result.data }; } diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index dda91112c7f..6c7020a9826 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1405,7 +1405,10 @@ describe('UserButton custom trigger badges', () => { it('hides the badge while pending and for null without changing the name', async () => { const badge = deferred<{ id: string; label: string } | null>(); - const { rerender } = await renderUserButton({ renderTriggerBadge: () => badge.promise }); + const renderTriggerBadge = vi.fn(({ id }: { id: string }) => + id === 'org_1' ? badge.promise : Promise.resolve(null), + ); + const { clerk } = await renderUserButton({ renderTriggerBadge }); expect(trigger()).toHaveTextContent('Acme'); expect(trigger()).not.toHaveTextContent('Pro plan'); @@ -1413,11 +1416,10 @@ describe('UserButton custom trigger badges', () => { await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument()); expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); - const renderTriggerBadge = vi.fn(() => Promise.resolve(null)); - rerender(tree({ renderTriggerBadge })); - await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalled()); + await act(() => clerk.setActive({ organization: 'org_9' })); + await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' })); expect(trigger()).not.toHaveTextContent('Pro plan'); - expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); + expect(within(trigger()).getByText('Other')).toBeInTheDocument(); }); it('discards the previous workspace badge after switching organizations', async () => { @@ -1436,29 +1438,51 @@ describe('UserButton custom trigger badges', () => { expect(trigger()).not.toHaveTextContent('Previous plan'); }); - it('refreshes the badge when the callback changes and hides it on failure or false', async () => { - const { rerender } = await renderUserButton({ - renderTriggerBadge: () => Promise.resolve({ id: 'pro', label: 'Pro plan' }), + it('keeps the badge mounted when opened with an inline callback', async () => { + const calls = vi.fn(); + const inline = () => { + calls(); + return Promise.resolve({ id: 'enterprise', label: 'Enterprise plan' }); + }; + const { rerender } = await renderUserButton({ renderTriggerBadge: () => inline() }); + await waitFor(() => expect(within(trigger()).getByText('Enterprise plan')).toBeInTheDocument()); + + let dropped = false; + const observer = new MutationObserver(() => { + dropped ||= !trigger().textContent?.includes('Enterprise plan'); }); - await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument()); + observer.observe(trigger(), { childList: true, subtree: true, characterData: true }); + onTestFinished(() => observer.disconnect()); - rerender(tree({ renderTriggerBadge: () => Promise.resolve({ id: 'business', label: 'Business plan' }) })); - await waitFor(() => expect(within(trigger()).getByText('Business plan')).toBeInTheDocument()); - expect(within(trigger()).getByText('Business plan').closest('.cl-badge')).toHaveAttribute( - 'data-badge-id', - 'business', - ); - expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); + await open(); + rerender(tree({ renderTriggerBadge: () => inline() })); + expect(within(trigger()).getByText('Enterprise plan')).toBeInTheDocument(); + await act(() => Promise.resolve()); - const fail = vi.fn().mockRejectedValue(new Error('Plan unavailable')); - rerender(tree({ renderTriggerBadge: fail })); - await waitFor(() => expect(fail).toHaveBeenCalled()); - expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); - expect(trigger()).not.toHaveTextContent('Business plan'); + expect(dropped).toBe(false); + expect(calls).toHaveBeenCalledTimes(1); + }); + + it('hides the badge for false or on failure', async () => { + const renderTriggerBadge = vi.fn(({ id }: { id: string }) => + id === 'org_1' + ? Promise.resolve({ id: 'pro', label: 'Pro plan' }) + : Promise.reject(new Error('Plan unavailable')), + ); + const { clerk, rerender } = await renderUserButton({ renderTriggerBadge }); + await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument()); rerender(tree({ renderTriggerBadge: false })); + expect(trigger()).not.toHaveTextContent('Pro plan'); expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); - expect(trigger()).not.toHaveTextContent('Business plan'); + + rerender(tree({ renderTriggerBadge })); + await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument()); + + await act(() => clerk.setActive({ organization: 'org_9' })); + await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' })); + expect(trigger()).not.toHaveTextContent('Pro plan'); + expect(within(trigger()).getByText('Other')).toBeInTheDocument(); }); it('does not request a badge when the trigger label is hidden', async () => {