Skip to content

Commit d775436

Browse files
authored
fix(clerk-js): Remove flickers from PricingTable when signed in (#6535)
1 parent 5b24129 commit d775436

File tree

3 files changed

+244
-3
lines changed

3 files changed

+244
-3
lines changed

.changeset/few-eagles-grab.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Remove flickers from PricingTable when signed in.

packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@ const PricingTableRoot = (props: PricingTableProps) => {
1212
const clerk = useClerk();
1313
const { mode = 'mounted', signInMode = 'redirect' } = usePricingTableContext();
1414
const isCompact = mode === 'modal';
15-
const { subscriptionItems } = useSubscription();
15+
const { data: subscription, subscriptionItems } = useSubscription();
1616
const { data: plans } = usePlans();
1717
const { handleSelectPlan } = usePlansContext();
1818

19+
const plansToRender = useMemo(() => {
20+
return clerk.isSignedIn
21+
? subscription // All users in billing-enabled applications have a subscription
22+
? plans
23+
: []
24+
: plans;
25+
}, [clerk.isSignedIn, plans, subscription]);
26+
1927
const defaultPlanPeriod = useMemo(() => {
2028
if (isCompact) {
2129
const upcomingSubscription = subscriptionItems?.find(sub => sub.status === 'upcoming');
@@ -72,15 +80,15 @@ const PricingTableRoot = (props: PricingTableProps) => {
7280
>
7381
{mode !== 'modal' && (props as any).layout === 'matrix' ? (
7482
<PricingTableMatrix
75-
plans={plans}
83+
plans={plansToRender}
7684
planPeriod={planPeriod}
7785
setPlanPeriod={setPlanPeriod}
7886
onSelect={selectPlan}
7987
highlightedPlan={(props as any).highlightPlan}
8088
/>
8189
) : (
8290
<PricingTableDefault
83-
plans={plans}
91+
plans={plansToRender}
8492
planPeriod={planPeriod}
8593
setPlanPeriod={setPlanPeriod}
8694
onSelect={selectPlan}

packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,231 @@ describe('PricingTable - trial info', () => {
175175
});
176176
});
177177
});
178+
179+
describe('PricingTable - plans visibility', () => {
180+
const testPlan = {
181+
id: 'plan_test',
182+
name: 'Test Plan',
183+
fee: {
184+
amount: 1000,
185+
amountFormatted: '10.00',
186+
currencySymbol: '$',
187+
currency: 'USD',
188+
},
189+
annualFee: {
190+
amount: 10000,
191+
amountFormatted: '100.00',
192+
currencySymbol: '$',
193+
currency: 'USD',
194+
},
195+
annualMonthlyFee: {
196+
amount: 833,
197+
amountFormatted: '8.33',
198+
currencySymbol: '$',
199+
currency: 'USD',
200+
},
201+
description: 'Test plan description',
202+
hasBaseFee: true,
203+
isRecurring: true,
204+
isDefault: false,
205+
forPayerType: 'user',
206+
publiclyVisible: true,
207+
slug: 'test',
208+
avatarUrl: '',
209+
features: [] as any[],
210+
freeTrialEnabled: false,
211+
freeTrialDays: 0,
212+
__internal_toSnapshot: jest.fn(),
213+
pathRoot: '',
214+
reload: jest.fn(),
215+
} as const;
216+
217+
it('shows no plans when user is signed in but has no subscription', async () => {
218+
const { wrapper, fixtures, props } = await createFixtures(f => {
219+
f.withUser({ email_addresses: ['test@clerk.com'] });
220+
});
221+
222+
// Provide empty props to the PricingTable context
223+
props.setProps({});
224+
225+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 });
226+
// Mock no subscription for signed-in user - empty subscription object
227+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
228+
subscriptionItems: [],
229+
pathRoot: '',
230+
reload: jest.fn(),
231+
} as any);
232+
233+
const { queryByRole } = render(<PricingTable />, { wrapper });
234+
235+
await waitFor(() => {
236+
// Should not show any plans when signed in but no subscription
237+
expect(queryByRole('heading', { name: 'Test Plan' })).not.toBeInTheDocument();
238+
});
239+
});
240+
241+
it('shows plans when user is signed in and has a subscription', async () => {
242+
const { wrapper, fixtures, props } = await createFixtures(f => {
243+
f.withUser({ email_addresses: ['test@clerk.com'] });
244+
});
245+
246+
// Provide empty props to the PricingTable context
247+
props.setProps({});
248+
249+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 });
250+
// Mock active subscription for signed-in user
251+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
252+
id: 'sub_active',
253+
status: 'active',
254+
activeAt: new Date('2021-01-01'),
255+
createdAt: new Date('2021-01-01'),
256+
nextPayment: null,
257+
pastDueAt: null,
258+
updatedAt: null,
259+
subscriptionItems: [
260+
{
261+
id: 'si_active',
262+
plan: testPlan,
263+
createdAt: new Date('2021-01-01'),
264+
paymentSourceId: 'src_1',
265+
pastDueAt: null,
266+
canceledAt: null,
267+
periodStart: new Date('2021-01-01'),
268+
periodEnd: new Date('2021-01-31'),
269+
planPeriod: 'month' as const,
270+
status: 'active' as const,
271+
isFreeTrial: false,
272+
cancel: jest.fn(),
273+
pathRoot: '',
274+
reload: jest.fn(),
275+
},
276+
],
277+
pathRoot: '',
278+
reload: jest.fn(),
279+
});
280+
281+
const { getByRole } = render(<PricingTable />, { wrapper });
282+
283+
await waitFor(() => {
284+
// Should show plans when signed in and has subscription
285+
expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible();
286+
});
287+
});
288+
289+
it('shows plans when user is signed out', async () => {
290+
const { wrapper, fixtures, props } = await createFixtures();
291+
292+
// Provide empty props to the PricingTable context
293+
props.setProps({});
294+
295+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 });
296+
// When signed out, getSubscription should throw or return empty response
297+
fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated'));
298+
299+
const { getByRole } = render(<PricingTable />, { wrapper });
300+
301+
await waitFor(() => {
302+
// Should show plans when signed out
303+
expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible();
304+
});
305+
});
306+
307+
it('shows no plans when user is signed in but subscription is null', async () => {
308+
const { wrapper, fixtures, props } = await createFixtures(f => {
309+
f.withUser({ email_addresses: ['test@clerk.com'] });
310+
});
311+
312+
// Provide empty props to the PricingTable context
313+
props.setProps({});
314+
315+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 });
316+
// Mock null subscription response (different from throwing error)
317+
fixtures.clerk.billing.getSubscription.mockResolvedValue(null as any);
318+
319+
const { queryByRole } = render(<PricingTable />, { wrapper });
320+
321+
await waitFor(() => {
322+
// Should not show any plans when signed in but subscription is null
323+
expect(queryByRole('heading', { name: 'Test Plan' })).not.toBeInTheDocument();
324+
});
325+
});
326+
327+
it('shows no plans when user is signed in but subscription is undefined', async () => {
328+
const { wrapper, fixtures, props } = await createFixtures(f => {
329+
f.withUser({ email_addresses: ['test@clerk.com'] });
330+
});
331+
332+
// Provide empty props to the PricingTable context
333+
props.setProps({});
334+
335+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 });
336+
// Mock undefined subscription response (loading state)
337+
fixtures.clerk.billing.getSubscription.mockResolvedValue(undefined as any);
338+
339+
const { queryByRole } = render(<PricingTable />, { wrapper });
340+
341+
await waitFor(() => {
342+
// Should not show any plans when signed in but subscription is undefined (loading)
343+
expect(queryByRole('heading', { name: 'Test Plan' })).not.toBeInTheDocument();
344+
});
345+
});
346+
347+
it('prevents flicker by not showing plans while subscription is loading', async () => {
348+
const { wrapper, fixtures, props } = await createFixtures(f => {
349+
f.withUser({ email_addresses: ['test@clerk.com'] });
350+
});
351+
352+
// Provide empty props to the PricingTable context
353+
props.setProps({});
354+
355+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 });
356+
357+
// Create a pending promise and capture its resolver
358+
let resolveSubscription!: (value: any) => void;
359+
const pendingSubscriptionPromise = new Promise<any>(resolve => {
360+
resolveSubscription = resolve;
361+
});
362+
fixtures.clerk.billing.getSubscription.mockReturnValue(pendingSubscriptionPromise);
363+
364+
const { queryByRole, findByRole } = render(<PricingTable />, { wrapper });
365+
366+
// Assert no plans render while subscription is pending
367+
await waitFor(() => {
368+
expect(queryByRole('heading', { name: 'Test Plan' })).not.toBeInTheDocument();
369+
});
370+
371+
// Resolve the subscription with an active subscription object
372+
resolveSubscription({
373+
id: 'sub_active',
374+
status: 'active',
375+
activeAt: new Date('2021-01-01'),
376+
createdAt: new Date('2021-01-01'),
377+
nextPayment: null,
378+
pastDueAt: null,
379+
updatedAt: null,
380+
subscriptionItems: [
381+
{
382+
id: 'si_active',
383+
plan: testPlan,
384+
createdAt: new Date('2021-01-01'),
385+
paymentSourceId: 'src_1',
386+
pastDueAt: null,
387+
canceledAt: null,
388+
periodStart: new Date('2021-01-01'),
389+
periodEnd: new Date('2021-01-31'),
390+
planPeriod: 'month' as const,
391+
status: 'active' as const,
392+
isFreeTrial: false,
393+
cancel: jest.fn(),
394+
pathRoot: '',
395+
reload: jest.fn(),
396+
},
397+
],
398+
pathRoot: '',
399+
reload: jest.fn(),
400+
});
401+
402+
// Assert the plan heading appears after subscription resolves
403+
await findByRole('heading', { name: 'Test Plan' });
404+
});
405+
});

0 commit comments

Comments
 (0)