Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bitter-paths-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Fixes a bug where `usePlans()` would display stale data even if the `for` property has changed.
75 changes: 73 additions & 2 deletions packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { renderHook, waitFor } from '@testing-library/react';
import { render, renderHook, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mockUser: any = { id: 'user_1' };
Expand All @@ -7,7 +8,10 @@ const mockOrganization: any = { id: 'org_1' };
const getPlansSpy = vi.fn((args: any) =>
Promise.resolve({
// pageSize maps to limit; default to 10 if missing
data: Array.from({ length: args.limit ?? args.pageSize ?? 10 }, (_, i) => ({ id: `plan_${i + 1}` })),
data: Array.from<Partial<BillingPlanResource>, Partial<BillingPlanResource>>(
{ length: args.limit ?? args.pageSize ?? 10 },
(_, i) => ({ id: `plan_${i + 1}`, forPayerType: args.for }),
),
total_count: 25,
}),
);
Expand Down Expand Up @@ -37,6 +41,8 @@ vi.mock('../../contexts', () => {
};
});

import type { BillingPlanResource } from '@clerk/types';

import { usePlans } from '../usePlans';
import { wrapper } from './wrapper';

Expand Down Expand Up @@ -112,4 +118,69 @@ describe('usePlans', () => {
expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', pageSize: 3, initialPage: 1 });
expect(result.current.data.length).toBe(3);
});

it('mounts user and organization hooks together and renders their respective data', async () => {
const DualPlans = () => {
const userPlans = usePlans({ initialPage: 1, pageSize: 2 });
const orgPlans = usePlans({ initialPage: 1, pageSize: 2, for: 'organization' } as any);

return (
<>
<div data-testid='user-count'>{userPlans.data.length}</div>
<div data-testid='org-count'>{orgPlans.data.length}</div>
</>
);
};

render(<DualPlans />, { wrapper });

await waitFor(() => expect(screen.getByTestId('user-count').textContent).toBe('2'));
await waitFor(() => expect(screen.getByTestId('org-count').textContent).toBe('2'));

expect(getPlansSpy).toHaveBeenCalledTimes(2);
const calls = getPlansSpy.mock.calls.map(c => c[0]);
expect(calls).toEqual(
expect.arrayContaining([
{ for: 'user', initialPage: 1, pageSize: 2 },
{ for: 'organization', initialPage: 1, pageSize: 2 },
]),
);

// Ensure orgId does not leak into the fetcher params
for (const call of calls) {
expect(call).not.toHaveProperty('orgId');
}
});

it('conditionally renders hooks based on prop passed to render', async () => {
const UserPlansCount = () => {
const userPlans = usePlans({ initialPage: 1, pageSize: 2 });
return <div data-testid='user-type'>{userPlans.data.map(p => p.forPayerType)[0]}</div>;
};

const OrgPlansCount = () => {
const orgPlans = usePlans({ initialPage: 1, pageSize: 2, for: 'organization' } as any);
return <div data-testid='org-type'>{orgPlans.data.map(p => p.forPayerType)[0]}</div>;
};

const Conditional = ({ showOrg }: { showOrg: boolean }) => (showOrg ? <OrgPlansCount /> : <UserPlansCount />);

const { rerender } = render(<Conditional showOrg={false} />, { wrapper });

await waitFor(() => expect(screen.getByTestId('user-type').textContent).toBe('user'));
expect(getPlansSpy).toHaveBeenCalledTimes(1);
expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'user', initialPage: 1, pageSize: 2 });

rerender(<Conditional showOrg />);

await waitFor(() => expect(screen.getByTestId('org-type').textContent).toBe('organization'));
expect(getPlansSpy).toHaveBeenCalledTimes(2);
const calls = getPlansSpy.mock.calls.map(c => c[0]);
expect(calls).toEqual(
expect.arrayContaining([
{ for: 'user', initialPage: 1, pageSize: 2 },
{ for: 'organization', initialPage: 1, pageSize: 2 },
]),
);
});
});
16 changes: 9 additions & 7 deletions packages/shared/src/react/hooks/createBillingPaginatedHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ export function createBillingPaginatedHook<TResource extends ClerkResource, TPar
): PaginatedResources<TResource, T extends { infinite: true } ? true : false> {
const { for: _for, ...paginationParams } = params || ({} as Partial<T>);

const safeFor = _for || 'user';

useAssertWrappedByClerkProvider(hookName);

const fetchFn = useFetcher(_for || 'user');
const fetchFn = useFetcher(safeFor);

const safeValues = useWithSafeValues(paginationParams, {
initialPage: 1,
Expand All @@ -74,17 +76,18 @@ export function createBillingPaginatedHook<TResource extends ClerkResource, TPar

clerk.telemetry?.record(eventMethodCalled(hookName));

const isForOrganization = safeFor === 'organization';

const hookParams =
typeof paginationParams === 'undefined'
? undefined
: ({
initialPage: safeValues.initialPage,
pageSize: safeValues.pageSize,
...(options?.unauthenticated ? {} : _for === 'organization' ? { orgId: organization?.id } : {}),
...(options?.unauthenticated ? {} : isForOrganization ? { orgId: organization?.id } : {}),
} as TParams);

const isOrganization = _for === 'organization';
const billingEnabled = isOrganization
const billingEnabled = isForOrganization
? environment?.commerceSettings.billing.organization.enabled
: environment?.commerceSettings.billing.user.enabled;

Expand All @@ -102,12 +105,11 @@ export function createBillingPaginatedHook<TResource extends ClerkResource, TPar
},
{
type: resourceType,
// userId: user?.id,
...(options?.unauthenticated
? {}
? { for: safeFor }
: {
userId: user?.id,
...(_for === 'organization' ? { orgId: organization?.id } : {}),
...(isForOrganization ? { orgId: organization?.id } : {}),
}),
},
);
Expand Down