Skip to content

Commit 48aea9a

Browse files
fix(mosaic): stop the trigger badge flickering with an inline renderTriggerBadge (#10456)
1 parent 85fef05 commit 48aea9a

4 files changed

Lines changed: 70 additions & 44 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
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.

‎packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen, waitFor, within } from '@testing-library/react';
1+
import { act, screen, waitFor, within } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { describe, expect, it, vi } from 'vitest';
44

@@ -310,16 +310,16 @@ describe('OrganizationSwitcher custom trigger badges', () => {
310310
});
311311

312312
it('hides the badge for null and keeps the organization name', async () => {
313-
const { rerender } = await renderSwitcher({
314-
renderTriggerBadge: () => Promise.resolve({ id: 'pro', label: 'Pro plan' }),
315-
});
313+
const renderTriggerBadge = vi.fn(({ id }: { id: string }) =>
314+
Promise.resolve(id === 'org_1' ? { id: 'pro', label: 'Pro plan' } : null),
315+
);
316+
const { clerk } = await renderSwitcher({ renderTriggerBadge });
316317
await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
317318

318-
const renderTriggerBadge = vi.fn(() => Promise.resolve(null));
319-
rerender(tree({ renderTriggerBadge }));
320-
await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalled());
319+
await act(() => clerk.setActive({ organization: 'org_9' }));
320+
await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' }));
321321
expect(trigger()).not.toHaveTextContent('Pro plan');
322-
expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
323-
expect(trigger()).toHaveAccessibleName('Open organization menu for Acme');
322+
expect(within(trigger()).getByText('Other')).toBeInTheDocument();
323+
expect(trigger()).toHaveAccessibleName('Open organization menu for Other');
324324
});
325325
});

‎packages/mosaic/src/features/switcher/switcher-trigger.controller.ts‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,17 @@ export function useSwitcherTriggerController({
1616
renderTriggerBadge: SwitcherTriggerBadge;
1717
renderTriggerLabel: boolean;
1818
}) {
19-
const request = useMemo(() => {
20-
const shouldRequestBadge = renderTriggerLabel && kind !== 'none' && id && typeof renderTriggerBadge === 'function';
19+
const shouldRequestBadge = renderTriggerLabel && typeof renderTriggerBadge === 'function';
20+
const request = useMemo(
21+
() => (kind !== 'none' && id ? { options: { kind, id }, sessionId } : undefined),
22+
[kind, id, sessionId],
23+
);
2124

22-
if (!shouldRequestBadge) {
23-
return undefined;
24-
}
25-
26-
return { options: { kind, id }, resolve: renderTriggerBadge, sessionId };
27-
}, [kind, id, renderTriggerBadge, renderTriggerLabel, sessionId]);
28-
29-
const result = useDebouncedAsync(request, async value => value?.resolve(value.options), {
30-
delayMs: 0,
31-
enabled: request !== undefined,
32-
});
25+
const result = useDebouncedAsync(
26+
request,
27+
async value => (value && typeof renderTriggerBadge === 'function' ? renderTriggerBadge(value.options) : undefined),
28+
{ delayMs: 0, enabled: shouldRequestBadge && request !== undefined },
29+
);
3330

3431
return { badge: result.isPending ? undefined : result.data };
3532
}

‎packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx‎

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,19 +1401,21 @@ describe('UserButton custom trigger badges', () => {
14011401

14021402
it('hides the badge while pending and for null without changing the name', async () => {
14031403
const badge = deferred<{ id: string; label: string } | null>();
1404-
const { rerender } = await renderUserButton({ renderTriggerBadge: () => badge.promise });
1404+
const renderTriggerBadge = vi.fn(({ id }: { id: string }) =>
1405+
id === 'org_1' ? badge.promise : Promise.resolve(null),
1406+
);
1407+
const { clerk } = await renderUserButton({ renderTriggerBadge });
14051408

14061409
expect(trigger()).toHaveTextContent('Acme');
14071410
expect(trigger()).not.toHaveTextContent('Pro plan');
14081411
await act(() => badge.resolve({ id: 'pro', label: 'Pro plan' }));
14091412
await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
14101413
expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
14111414

1412-
const renderTriggerBadge = vi.fn(() => Promise.resolve(null));
1413-
rerender(tree({ renderTriggerBadge }));
1414-
await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalled());
1415+
await act(() => clerk.setActive({ organization: 'org_9' }));
1416+
await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' }));
14151417
expect(trigger()).not.toHaveTextContent('Pro plan');
1416-
expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
1418+
expect(within(trigger()).getByText('Other')).toBeInTheDocument();
14171419
});
14181420

14191421
it('discards the previous workspace badge after switching organizations', async () => {
@@ -1432,29 +1434,51 @@ describe('UserButton custom trigger badges', () => {
14321434
expect(trigger()).not.toHaveTextContent('Previous plan');
14331435
});
14341436

1435-
it('refreshes the badge when the callback changes and hides it on failure or false', async () => {
1436-
const { rerender } = await renderUserButton({
1437-
renderTriggerBadge: () => Promise.resolve({ id: 'pro', label: 'Pro plan' }),
1437+
it('keeps the badge mounted when opened with an inline callback', async () => {
1438+
const calls = vi.fn();
1439+
const inline = () => {
1440+
calls();
1441+
return Promise.resolve({ id: 'enterprise', label: 'Enterprise plan' });
1442+
};
1443+
const { rerender } = await renderUserButton({ renderTriggerBadge: () => inline() });
1444+
await waitFor(() => expect(within(trigger()).getByText('Enterprise plan')).toBeInTheDocument());
1445+
1446+
let dropped = false;
1447+
const observer = new MutationObserver(() => {
1448+
dropped ||= !trigger().textContent?.includes('Enterprise plan');
14381449
});
1439-
await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
1450+
observer.observe(trigger(), { childList: true, subtree: true, characterData: true });
1451+
onTestFinished(() => observer.disconnect());
14401452

1441-
rerender(tree({ renderTriggerBadge: () => Promise.resolve({ id: 'business', label: 'Business plan' }) }));
1442-
await waitFor(() => expect(within(trigger()).getByText('Business plan')).toBeInTheDocument());
1443-
expect(within(trigger()).getByText('Business plan').closest('.cl-badge')).toHaveAttribute(
1444-
'data-badge-id',
1445-
'business',
1446-
);
1447-
expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
1453+
await open();
1454+
rerender(tree({ renderTriggerBadge: () => inline() }));
1455+
expect(within(trigger()).getByText('Enterprise plan')).toBeInTheDocument();
1456+
await act(() => Promise.resolve());
14481457

1449-
const fail = vi.fn().mockRejectedValue(new Error('Plan unavailable'));
1450-
rerender(tree({ renderTriggerBadge: fail }));
1451-
await waitFor(() => expect(fail).toHaveBeenCalled());
1452-
expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
1453-
expect(trigger()).not.toHaveTextContent('Business plan');
1458+
expect(dropped).toBe(false);
1459+
expect(calls).toHaveBeenCalledTimes(1);
1460+
});
1461+
1462+
it('hides the badge for false or on failure', async () => {
1463+
const renderTriggerBadge = vi.fn(({ id }: { id: string }) =>
1464+
id === 'org_1'
1465+
? Promise.resolve({ id: 'pro', label: 'Pro plan' })
1466+
: Promise.reject(new Error('Plan unavailable')),
1467+
);
1468+
const { clerk, rerender } = await renderUserButton({ renderTriggerBadge });
1469+
await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
14541470

14551471
rerender(tree({ renderTriggerBadge: false }));
1472+
expect(trigger()).not.toHaveTextContent('Pro plan');
14561473
expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
1457-
expect(trigger()).not.toHaveTextContent('Business plan');
1474+
1475+
rerender(tree({ renderTriggerBadge }));
1476+
await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
1477+
1478+
await act(() => clerk.setActive({ organization: 'org_9' }));
1479+
await waitFor(() => expect(renderTriggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' }));
1480+
expect(trigger()).not.toHaveTextContent('Pro plan');
1481+
expect(within(trigger()).getByText('Other')).toBeInTheDocument();
14581482
});
14591483

14601484
it('does not request a badge when the trigger label is hidden', async () => {

0 commit comments

Comments
 (0)