diff --git a/.changeset/fuzzy-keys-smell.md b/.changeset/fuzzy-keys-smell.md new file mode 100644 index 00000000000..3c72288937e --- /dev/null +++ b/.changeset/fuzzy-keys-smell.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Build internal variants of all paginated hooks that use React Query instead of SWR. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbdd135382a..557276e735e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: unit-tests: needs: [check-permissions, build-packages] if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} - name: Unit Tests + name: Unit Tests (${{ matrix.node-version }}, ${{ matrix.filter-label }}${{ matrix.clerk-use-rq == 'true' && ', RQ' || '' }}) permissions: contents: read actions: write # needed for actions/upload-artifact @@ -205,11 +205,17 @@ jobs: TURBO_SUMMARIZE: false strategy: - fail-fast: true + fail-fast: false matrix: include: - node-version: 22 test-filter: "**" + clerk-use-rq: "false" + filter-label: "**" + - node-version: 22 + test-filter: "--filter=@clerk/shared --filter=@clerk/clerk-js" + clerk-use-rq: "true" + filter-label: "shared, clerk-js" steps: - name: Checkout Repo @@ -229,22 +235,35 @@ jobs: turbo-team: ${{ vars.TURBO_TEAM }} turbo-token: ${{ secrets.TURBO_TOKEN }} + - name: Rebuild @clerk/shared with CLERK_USE_RQ=true + if: ${{ matrix.clerk-use-rq == 'true' }} + run: pnpm turbo build $TURBO_ARGS --filter=@clerk/shared --force + env: + CLERK_USE_RQ: true + + - name: Rebuild dependent packages with CLERK_USE_RQ=true + if: ${{ matrix.clerk-use-rq == 'true' }} + run: pnpm turbo build $TURBO_ARGS --filter=@clerk/shared^... --force + env: + CLERK_USE_RQ: true + - name: Run tests in packages run: | if [ "${{ matrix.test-filter }}" = "**" ]; then - echo "Running full test suite on Node ${{ matrix.node-version }}." + echo "Running full test suite on Node ${{ matrix.node-version }}" pnpm turbo test $TURBO_ARGS else - echo "Running LTS subset on Node ${{ matrix.node-version }}." + echo "Running tests: ${{ matrix.filter-label }}" pnpm turbo test $TURBO_ARGS ${{ matrix.test-filter }} fi env: NODE_VERSION: ${{ matrix.node-version }} + CLERK_USE_RQ: ${{ matrix.clerk-use-rq }} - name: Run Typedoc tests run: | - # Only run Typedoc tests for one matrix version - if [ "${{ matrix.node-version }}" == "22" ]; then + # Only run Typedoc tests for one matrix version and main test run + if [ "${{ matrix.node-version }}" == "22" ] && [ "${{ matrix.test-filter }}" = "**" ]; then pnpm test:typedoc fi env: @@ -255,14 +274,14 @@ jobs: if: ${{ env.TURBO_SUMMARIZE == 'true' }} continue-on-error: true with: - name: turbo-summary-report-unit-${{ github.run_id }}-${{ github.run_attempt }}-node-${{ matrix.node-version }} + name: turbo-summary-report-unit-${{ github.run_id }}-${{ github.run_attempt }}-node-${{ matrix.node-version }}${{ matrix.clerk-use-rq == 'true' && '-rq' || '' }} path: .turbo/runs retention-days: 5 integration-tests: needs: [check-permissions, build-packages] if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} - name: Integration Tests + name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }}${{ matrix.next-version && format(', {0}', matrix.next-version) || '' }}${{ matrix.clerk-use-rq == 'true' && ', RQ' || '' }}) permissions: contents: read actions: write # needed for actions/upload-artifact @@ -291,18 +310,28 @@ jobs: 'vue', 'nuxt', 'react-router', - 'billing', 'machine', 'custom', ] test-project: ["chrome"] include: + - test-name: 'billing' + test-project: 'chrome' + clerk-use-rq: 'false' + - test-name: 'billing' + test-project: 'chrome' + clerk-use-rq: 'true' - test-name: 'nextjs' test-project: 'chrome' next-version: '14' - test-name: 'nextjs' test-project: 'chrome' next-version: '15' + clerk-use-rq: 'false' + - test-name: 'nextjs' + test-project: 'chrome' + next-version: '15' + clerk-use-rq: 'true' - test-name: 'nextjs' test-project: 'chrome' next-version: '16' @@ -360,12 +389,24 @@ jobs: echo "affected=${AFFECTED}" echo "affected=${AFFECTED}" >> $GITHUB_OUTPUT + - name: Rebuild @clerk/shared with CLERK_USE_RQ=true + if: ${{ steps.task-status.outputs.affected == '1' && matrix.clerk-use-rq == 'true' }} + run: pnpm turbo build $TURBO_ARGS --filter=@clerk/shared --force + env: + CLERK_USE_RQ: true + + - name: Rebuild dependent packages with CLERK_USE_RQ=true + if: ${{ steps.task-status.outputs.affected == '1' && matrix.clerk-use-rq == 'true' }} + run: pnpm turbo build $TURBO_ARGS --filter=@clerk/shared^... --force + env: + CLERK_USE_RQ: true + - name: Verdaccio if: ${{ steps.task-status.outputs.affected == '1' }} uses: ./.github/actions/verdaccio with: publish-cmd: | - if [ "$(pnpm config get registry)" = "https://registry.npmjs.org/" ]; then echo 'Error: Using default registry' && exit 1; else pnpm turbo build $TURBO_ARGS --only && pnpm changeset publish --no-git-tag; fi + if [ "$(pnpm config get registry)" = "https://registry.npmjs.org/" ]; then echo 'Error: Using default registry' && exit 1; else CLERK_USE_RQ=${{ matrix.clerk-use-rq }} pnpm turbo build $TURBO_ARGS --only && pnpm changeset publish --no-git-tag; fi - name: Edit .npmrc [link-workspace-packages=false] run: sed -i -E 's/link-workspace-packages=(deep|true)/link-workspace-packages=false/' .npmrc @@ -425,6 +466,7 @@ jobs: E2E_NEXTJS_VERSION: ${{ matrix.next-version }} E2E_PROJECT: ${{ matrix.test-project }} E2E_CLERK_ENCRYPTION_KEY: ${{ matrix.clerk-encryption-key }} + CLERK_USE_RQ: ${{ matrix.clerk-use-rq }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }} NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem @@ -433,7 +475,7 @@ jobs: if: ${{ cancelled() || failure() }} uses: actions/upload-artifact@v4 with: - name: playwright-traces-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.test-name }} + name: playwright-traces-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.test-name }}${{ matrix.next-version && format('-next{0}', matrix.next-version) || '' }}${{ matrix.clerk-use-rq == 'true' && '-rq' || '' }} path: integration/test-results retention-days: 1 diff --git a/packages/clerk-js/src/test/mock-helpers.ts b/packages/clerk-js/src/test/mock-helpers.ts index 0489976266b..d76dea115bb 100644 --- a/packages/clerk-js/src/test/mock-helpers.ts +++ b/packages/clerk-js/src/test/mock-helpers.ts @@ -1,6 +1,7 @@ import type { ActiveSessionResource, LoadedClerk } from '@clerk/shared/types'; import { type Mocked, vi } from 'vitest'; +import { QueryClient } from '../core/query-core'; import type { RouteContextValue } from '../ui/router'; type FunctionLike = (...args: any) => any; @@ -45,6 +46,20 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked defaultQueryClient), + configurable: true, + }); + mockProp(clerkAny, 'navigate'); mockProp(clerkAny, 'setActive'); mockProp(clerkAny, 'redirectWithAuth'); diff --git a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx index bef2787d561..dfd6aee4b74 100644 --- a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx @@ -316,6 +316,12 @@ describe('Checkout', () => { }); const freeTrialEndsAt = new Date('2025-08-19'); + + fixtures.clerk.user?.getPaymentMethods.mockResolvedValue({ + data: [], + total_count: 0, + }); + fixtures.clerk.billing.startCheckout.mockResolvedValue({ id: 'chk_trial_1', status: 'needs_confirmation', @@ -1033,13 +1039,18 @@ describe('Checkout', () => { { wrapper }, ); - await waitFor(async () => { + await waitFor(() => { expect(getByRole('heading', { name: 'Checkout' })).toBeVisible(); - const addPaymentMethodButton = getByText('Add payment method'); - expect(addPaymentMethodButton).toBeVisible(); - await userEvent.click(addPaymentMethodButton); }); + const addPaymentMethodButton = await waitFor(() => { + const button = getByText('Add payment method'); + expect(button).toBeVisible(); + return button; + }); + + await userEvent.click(addPaymentMethodButton); + await waitFor(() => { expect(getByRole('button', { name: 'Start free trial' })).toBeInTheDocument(); }); diff --git a/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx b/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx index 09ad363316b..94703a98a02 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx @@ -45,7 +45,7 @@ describe('OrganizationList', () => { }); }); - fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( Promise.resolve({ data: [ createFakeUserOrganizationMembership({ @@ -117,7 +117,7 @@ describe('OrganizationList', () => { }); }); - fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( Promise.resolve({ data: [ createFakeUserOrganizationMembership({ @@ -156,7 +156,7 @@ describe('OrganizationList', () => { }), ); - fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( Promise.resolve({ data: [invitation], total_count: 1, @@ -342,7 +342,7 @@ describe('OrganizationList', () => { }); await waitFor(async () => { - fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); + fixtures.clerk.setActive.mockReturnValue(Promise.resolve()); await userEvent.click(getByText(/Personal account/i)); expect(fixtures.router.navigate).toHaveBeenCalledWith(`/user/test_user_id`); @@ -376,7 +376,7 @@ describe('OrganizationList', () => { }, }); - fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( Promise.resolve({ data: [membership], total_count: 1, @@ -392,7 +392,7 @@ describe('OrganizationList', () => { }); await waitFor(async () => { - fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); + fixtures.clerk.setActive.mockReturnValue(Promise.resolve()); await userEvent.click(getByRole('button', { name: /Org1/i })); expect(fixtures.clerk.setActive).toHaveBeenCalledWith( expect.objectContaining({ @@ -423,7 +423,7 @@ describe('OrganizationList', () => { wrapper, }); - fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); + fixtures.clerk.setActive.mockReturnValue(Promise.resolve()); await waitFor(async () => expect(await findByRole('menuitem', { name: 'Create organization' })).toBeInTheDocument(), ); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx index 2a996f95154..b809bf3d936 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx @@ -30,6 +30,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); const { findByText, getByText } = render( @@ -56,6 +57,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -115,6 +117,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 1, data: [ @@ -152,6 +155,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 1, data: [ @@ -203,6 +207,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 3, data: [ @@ -266,6 +271,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -320,6 +326,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -377,6 +384,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -437,6 +445,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -493,6 +502,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -563,6 +573,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ @@ -629,6 +640,7 @@ describe('InviteMembersPage', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockResolvedValue({ total_count: 2, data: [ diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx index b56949687a3..1f9596b427e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx @@ -30,6 +30,8 @@ describe('OrganizationMembers', () => { f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); const { container, getByRole } = render(, { wrapper }); @@ -49,6 +51,9 @@ describe('OrganizationMembers', () => { f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); + fixtures.clerk.organization?.getMembershipRequests.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); const { getByRole, container } = render(, { wrapper }); @@ -64,6 +69,8 @@ describe('OrganizationMembers', () => { f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); const { getByRole, findByText } = render(, { wrapper }); @@ -82,6 +89,7 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); const { container, queryByRole } = render(, { wrapper }); @@ -149,6 +157,8 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockReturnValueOnce( Promise.resolve({ data: membersList, @@ -219,7 +229,9 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.organization?.getMemberships.mockReturnValueOnce( + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + + fixtures.clerk.organization?.getMemberships.mockReturnValue( Promise.resolve({ data: [], total_count: 14, @@ -253,7 +265,9 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.organization?.getMemberships.mockReturnValueOnce( + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + + fixtures.clerk.organization?.getMemberships.mockReturnValue( Promise.resolve({ data: [], total_count: 5, @@ -300,11 +314,9 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.organization?.getMemberships.mockReturnValueOnce( - Promise.resolve({ data: membersList, total_count: 0 }), - ); + fixtures.clerk.organization?.getMemberships.mockReturnValue(Promise.resolve({ data: membersList, total_count: 0 })); - fixtures.clerk.organization?.getMemberships.mockReturnValueOnce( + fixtures.clerk.organization?.getMemberships.mockReturnValue( Promise.resolve({ data: [], total_count: 0, @@ -328,6 +340,9 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); + fixtures.clerk.organization?.getRoles.mockRejectedValue(null); fixtures.clerk.organization?.getMembershipRequests.mockReturnValue( Promise.resolve({ @@ -367,6 +382,7 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); fixtures.clerk.organization?.getInvitations.mockReturnValue( @@ -421,6 +437,9 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); + fixtures.clerk.organization?.getRoles.mockRejectedValue(null); fixtures.clerk.organization?.getDomains.mockReturnValue( @@ -460,6 +479,7 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); fixtures.clerk.organization?.getMemberships.mockReturnValue( Promise.resolve({ @@ -495,6 +515,8 @@ describe('OrganizationMembers', () => { }); }); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockReturnValue( Promise.resolve({ data: membersList, @@ -550,6 +572,8 @@ describe('OrganizationMembers', () => { }); fixtures.clerk.organization?.getRoles.mockRejectedValue(null); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); const { container, getByRole } = render(, { wrapper }); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx index 028cd9bebfd..7675a35f834 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx @@ -70,6 +70,9 @@ describe('OrganizationProfile', () => { fixtures.environment.commerceSettings.billing.organization.enabled = true; fixtures.environment.commerceSettings.billing.organization.hasPaidPlans = false; + fixtures.clerk.billing.getSubscription.mockRejectedValue(null); + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', subscriptionItems: [ @@ -105,6 +108,10 @@ describe('OrganizationProfile', () => { render(, { wrapper }); await waitFor(() => expect(screen.queryByText('Billing')).toBeNull()); + + // TODO(@RQ_MIGRATION): Offer a way to disable these, because they fire unnecessary requests. + expect(fixtures.clerk.billing.getSubscription).toHaveBeenCalled(); + expect(fixtures.clerk.billing.getStatements).toHaveBeenCalled(); }); it('does not include Billing when organization billing is disabled', async () => { const { wrapper, fixtures } = await createFixtures(f => { @@ -168,8 +175,13 @@ describe('OrganizationProfile', () => { fixtures.environment.commerceSettings.billing.organization.enabled = true; fixtures.environment.commerceSettings.billing.organization.hasPaidPlans = true; + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.billing.getSubscription.mockRejectedValue(null); + render(, { wrapper }); expect(await screen.findByText('Billing')).toBeDefined(); + expect(fixtures.clerk.billing.getSubscription).toHaveBeenCalled(); + expect(fixtures.clerk.billing.getStatements).toHaveBeenCalled(); }); it('does not include Billing in organization when user billing has paid plans but organization billing is disabled', async () => { @@ -213,6 +225,7 @@ describe('OrganizationProfile', () => { fixtures.environment.commerceSettings.billing.organization.enabled = true; fixtures.environment.commerceSettings.billing.organization.hasPaidPlans = false; + fixtures.clerk.billing.getStatements.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', subscriptionItems: [ diff --git a/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx b/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx index cdfcf4efbe3..cdc94b9ae3f 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils/index'; import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; @@ -54,6 +55,8 @@ describe('PricingTable - trial info', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_1', @@ -113,6 +116,8 @@ describe('PricingTable - trial info', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', @@ -151,6 +156,7 @@ describe('PricingTable - trial info', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 }); // When signed out, getSubscription should throw or return empty response fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated')); @@ -182,6 +188,7 @@ describe('PricingTable - trial info', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [nonTrialPlan as any], total_count: 1 }); // When signed out, getSubscription should throw or return empty response fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated')); @@ -210,6 +217,8 @@ describe('PricingTable - trial info', () => { freeTrialDays: 0, }; + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [nonTrialPlan as any], total_count: 1 }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_1', @@ -302,6 +311,8 @@ describe('PricingTable - plans visibility', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); // Mock no subscription for signed-in user - empty subscription object fixtures.clerk.billing.getSubscription.mockResolvedValue({ @@ -327,6 +338,8 @@ describe('PricingTable - plans visibility', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); // Mock active subscription for signed-in user fixtures.clerk.billing.getSubscription.mockResolvedValue({ @@ -374,6 +387,7 @@ describe('PricingTable - plans visibility', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); // When signed out, getSubscription should throw or return empty response fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated')); @@ -395,6 +409,8 @@ describe('PricingTable - plans visibility', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); // Mock null subscription response (different from throwing error) fixtures.clerk.billing.getSubscription.mockResolvedValue(null as any); @@ -416,16 +432,24 @@ describe('PricingTable - plans visibility', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); - // Mock undefined subscription response (loading state) - fixtures.clerk.billing.getSubscription.mockResolvedValue(undefined as any); + const resolver = createDeferredPromise(); + // Mock undefined subscription response (loading state) + fixtures.clerk.billing.getSubscription.mockResolvedValue(resolver.promise); const { queryByRole } = render(, { wrapper }); await waitFor(() => { // Should not show any plans when signed in but subscription is undefined (loading) expect(queryByRole('heading', { name: 'Test Plan' })).not.toBeInTheDocument(); }); + resolver.resolve([]); + await waitFor(() => { + // Should not show any plans when signed in but subscription is undefined (loading) + expect(queryByRole('heading', { name: 'Test Plan' })).toBeInTheDocument(); + }); }); it('prevents flicker by not showing plans while subscription is loading', async () => { @@ -437,6 +461,8 @@ describe('PricingTable - plans visibility', () => { // Provide empty props to the PricingTable context props.setProps({}); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); // Create a pending promise and capture its resolver @@ -497,6 +523,8 @@ describe('PricingTable - plans visibility', () => { // Set legacy prop via context provider props.setProps({ forOrganizations: true } as any); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.organization.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_org_active', @@ -550,6 +578,8 @@ describe('PricingTable - plans visibility', () => { // Set new prop via context provider props.setProps({ for: 'organization' } as any); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.organization.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_org_active', @@ -602,6 +632,8 @@ describe('PricingTable - plans visibility', () => { // Set new prop via context provider props.setProps({ for: 'user' } as any); + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(); fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [testPlan as any], total_count: 1 }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_active', diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index bb0dbffce26..999c1a47e4a 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -10,11 +10,13 @@ const { createFixtures } = bindCreateFixtures('SubscriptionDetails'); describe('SubscriptionDetails', () => { it('Displays spinner when init loading', async () => { - const { wrapper } = await createFixtures(f => { + const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); f.withBilling(); }); + fixtures.clerk.billing.getSubscription.mockResolvedValue(null); + const { baseElement } = render( { f.withUser({ email_addresses: ['test@clerk.com'] }); f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); fixtures.clerk.billing.getSubscription.mockResolvedValue({ activeAt: new Date('2021-01-01'), @@ -139,6 +145,10 @@ describe('SubscriptionDetails', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); fixtures.clerk.billing.getSubscription.mockResolvedValue({ activeAt: new Date('2021-01-01'), @@ -242,6 +252,10 @@ describe('SubscriptionDetails', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); fixtures.clerk.billing.getSubscription.mockResolvedValue({ activeAt: new Date('2021-01-01'), @@ -325,6 +339,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + const planAnnual = { id: 'plan_annual', name: 'Annual Plan', @@ -483,6 +502,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + const planMonthly = { id: 'plan_monthly', name: 'Monthly Plan', @@ -620,6 +644,11 @@ describe('SubscriptionDetails', () => { const cancelSubscriptionMock = vi.fn().mockResolvedValue({}); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + fixtures.clerk.billing.getSubscription.mockResolvedValue({ activeAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'), @@ -722,6 +751,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + const plan = { id: 'plan_annual', name: 'Annual Plan', @@ -824,6 +858,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + const plan = { id: 'plan_annual', name: 'Annual Plan', @@ -929,6 +968,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + const plan = { id: 'plan_monthly', name: 'Monthly Plan', @@ -1018,6 +1062,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + fixtures.clerk.billing.getSubscription.mockResolvedValue({ activeAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'), @@ -1126,6 +1175,11 @@ describe('SubscriptionDetails', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockResolvedValue([]); + fixtures.clerk.billing.getStatements.mockResolvedValue([]); + fixtures.clerk.billing.getPaymentAttempts.mockResolvedValue([]); + fixtures.clerk.user.getPaymentMethods.mockResolvedValue([]); + const cancelSubscriptionMock = vi.fn().mockResolvedValue({}); fixtures.clerk.billing.getSubscription.mockResolvedValue({ diff --git a/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx b/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx index 05e0a873412..9993b9db8d2 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx @@ -27,6 +27,7 @@ describe('SubscriptionsList', () => { f.withBilling(); }); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top_empty', status: 'active', @@ -47,6 +48,10 @@ describe('SubscriptionsList', () => { }); expect(queryByText('Manage')).toBeNull(); + expect(fixtures.clerk.billing.getPlans).not.toHaveBeenCalled(); + expect(fixtures.clerk.billing.getStatements).not.toHaveBeenCalled(); + expect(fixtures.clerk.billing.getSubscription).toHaveBeenCalled(); + expect(fixtures.clerk.user.getPaymentMethods).toHaveBeenCalled(); }); it('shows switch plans CTA and hides Manage when there on free plan', async () => { @@ -55,6 +60,9 @@ describe('SubscriptionsList', () => { f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockRejectedValue(null); + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top_empty', status: 'active', @@ -164,6 +172,9 @@ describe('SubscriptionsList', () => { reload: vi.fn(), }; + fixtures.clerk.billing.getPlans.mockRejectedValue(null); + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', status: 'active', @@ -227,6 +238,9 @@ describe('SubscriptionsList', () => { reload: vi.fn(), }; + fixtures.clerk.billing.getPlans.mockRejectedValue(null); + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', status: 'past_due', @@ -255,6 +269,9 @@ describe('SubscriptionsList', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); f.withBilling(); }); + fixtures.clerk.billing.getPlans.mockRejectedValue(null); + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(null); const activeSubscription = { id: 'sub_active', @@ -389,6 +406,9 @@ describe('SubscriptionsList', () => { reload: vi.fn(), }; + fixtures.clerk.billing.getPlans.mockRejectedValue(null); + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.user.getPaymentMethods.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', status: 'active', diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx index 3cb9884bb50..6cc25246b45 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx @@ -106,9 +106,15 @@ describe('UserProfile', () => { fixtures.environment.commerceSettings.billing.user.enabled = true; fixtures.environment.commerceSettings.billing.user.hasPaidPlans = true; + fixtures.clerk.billing.getStatements.mockRejectedValue(null); + fixtures.clerk.billing.getSubscription.mockRejectedValue(null); + render(, { wrapper }); const billingElements = await screen.findAllByRole('button', { name: /Billing/i }); expect(billingElements.length).toBeGreaterThan(0); + + expect(fixtures.clerk.billing.getSubscription).toHaveBeenCalled(); + expect(fixtures.clerk.billing.getStatements).toHaveBeenCalled(); }); it('includes Billing when enabled and user has a non-free subscription', async () => { @@ -119,6 +125,7 @@ describe('UserProfile', () => { fixtures.environment.commerceSettings.billing.user.enabled = true; fixtures.environment.commerceSettings.billing.user.hasPaidPlans = false; + fixtures.clerk.billing.getStatements.mockRejectedValue(null); fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', subscriptionItems: [ diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganization.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganization.test.tsx index 712de8d701f..ff404b672e8 100644 --- a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganization.test.tsx +++ b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganization.test.tsx @@ -103,6 +103,10 @@ describe('useOrganization', () => { }); }); + fixtures.clerk.organization?.getDomains.mockRejectedValue(null); + fixtures.clerk.organization?.getMembershipRequests.mockRejectedValue(null); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMemberships.mockReturnValue( Promise.resolve({ data: [ @@ -226,6 +230,10 @@ describe('useOrganization', () => { }); }); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); + fixtures.clerk.organization?.getMembershipRequests.mockRejectedValue(null); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getDomains.mockReturnValue( Promise.resolve({ data: [ @@ -322,6 +330,10 @@ describe('useOrganization', () => { }); }); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); + fixtures.clerk.organization?.getDomains.mockRejectedValue(null); + fixtures.clerk.organization?.getInvitations.mockRejectedValue(null); + fixtures.clerk.organization?.getMembershipRequests.mockReturnValue( Promise.resolve({ data: [ @@ -426,6 +438,10 @@ describe('useOrganization', () => { }); }); + fixtures.clerk.organization?.getMemberships.mockRejectedValue(null); + fixtures.clerk.organization?.getDomains.mockRejectedValue(null); + fixtures.clerk.organization?.getMembershipRequests.mockRejectedValue(null); + fixtures.clerk.organization?.getInvitations.mockReturnValue( Promise.resolve({ data: [ diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx index d31d85c9cc0..9da8a5668a3 100644 --- a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx +++ b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx @@ -295,6 +295,9 @@ describe('useOrganizationList', () => { }); }); + fixtures.clerk.user?.getOrganizationMemberships.mockRejectedValue(null); + fixtures.clerk.user?.getOrganizationSuggestions.mockRejectedValue(null); + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( Promise.resolve({ data: [ @@ -458,6 +461,9 @@ describe('useOrganizationList', () => { }); }); + fixtures.clerk.user?.getOrganizationMemberships.mockRejectedValue(null); + fixtures.clerk.user?.getOrganizationInvitations.mockRejectedValue(null); + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValue( Promise.resolve({ data: [ diff --git a/packages/shared/global.d.ts b/packages/shared/global.d.ts index 5776b61ae17..3f83ebb197f 100644 --- a/packages/shared/global.d.ts +++ b/packages/shared/global.d.ts @@ -2,6 +2,7 @@ declare const PACKAGE_NAME: string; declare const PACKAGE_VERSION: string; declare const JS_PACKAGE_VERSION: string; declare const __DEV__: boolean; +declare const __CLERK_USE_RQ__: boolean; interface ImportMetaEnv { readonly [key: string]: string; diff --git a/packages/shared/src/react/clerk-rq/infiniteQueryOptions.ts b/packages/shared/src/react/clerk-rq/infiniteQueryOptions.ts new file mode 100644 index 00000000000..4b297583852 --- /dev/null +++ b/packages/shared/src/react/clerk-rq/infiniteQueryOptions.ts @@ -0,0 +1,94 @@ +import type { + DataTag, + DefaultError, + InfiniteData, + InitialDataFunction, + NonUndefinedGuard, + OmitKeyof, + QueryKey, + SkipToken, +} from '@tanstack/query-core'; + +import type { UseInfiniteQueryOptions } from './types'; + +export type UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = UseInfiniteQueryOptions & { + initialData?: + | undefined + | NonUndefinedGuard> + | InitialDataFunction>>; +}; + +export type UnusedSkipTokenInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = OmitKeyof, 'queryFn'> & { + queryFn?: Exclude< + UseInfiniteQueryOptions['queryFn'], + SkipToken | undefined + >; +}; + +export type DefinedInitialDataInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = UseInfiniteQueryOptions & { + initialData: + | NonUndefinedGuard> + | (() => NonUndefinedGuard>) + | undefined; +}; + +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: DefinedInitialDataInfiniteOptions, +): DefinedInitialDataInfiniteOptions & { + queryKey: DataTag, TError>; +}; + +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UnusedSkipTokenInfiniteOptions, +): UnusedSkipTokenInfiniteOptions & { + queryKey: DataTag, TError>; +}; + +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UndefinedInitialDataInfiniteOptions, +): UndefinedInitialDataInfiniteOptions & { + queryKey: DataTag, TError>; +}; + +/** + * + */ +export function infiniteQueryOptions(options: unknown) { + return options; +} diff --git a/packages/shared/src/react/clerk-rq/types.ts b/packages/shared/src/react/clerk-rq/types.ts index 6f5bcbfbc8d..09a0538467f 100644 --- a/packages/shared/src/react/clerk-rq/types.ts +++ b/packages/shared/src/react/clerk-rq/types.ts @@ -1,7 +1,9 @@ import type { DefaultError, + DefinedInfiniteQueryObserverResult, DefinedQueryObserverResult, InfiniteQueryObserverOptions, + InfiniteQueryObserverResult, OmitKeyof, QueryKey, QueryObserverOptions, @@ -52,3 +54,10 @@ export type UseBaseQueryResult = QueryOb export type UseQueryResult = UseBaseQueryResult; export type DefinedUseQueryResult = DefinedQueryObserverResult; + +export type UseInfiniteQueryResult = InfiniteQueryObserverResult; + +export type DefinedUseInfiniteQueryResult = DefinedInfiniteQueryObserverResult< + TData, + TError +>; diff --git a/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts b/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts new file mode 100644 index 00000000000..8f9104a7145 --- /dev/null +++ b/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts @@ -0,0 +1,44 @@ +'use client'; + +import type { DefaultError, InfiniteData, QueryKey, QueryObserver } from '@tanstack/query-core'; +import { InfiniteQueryObserver } from '@tanstack/query-core'; + +import type { DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions } from './infiniteQueryOptions'; +import type { DefinedUseInfiniteQueryResult, UseInfiniteQueryOptions, UseInfiniteQueryResult } from './types'; +import { useBaseQuery } from './useBaseQuery'; + +export function useClerkInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: DefinedInitialDataInfiniteOptions, +): DefinedUseInfiniteQueryResult; + +export function useClerkInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UndefinedInitialDataInfiniteOptions, +): UseInfiniteQueryResult; + +export function useClerkInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UseInfiniteQueryOptions, +): UseInfiniteQueryResult; +/** + * + */ +export function useClerkInfiniteQuery(options: UseInfiniteQueryOptions) { + return useBaseQuery(options, InfiniteQueryObserver as unknown as typeof QueryObserver); +} diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx index 748b673191e..d916bc2d856 100644 --- a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx @@ -3,23 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ClerkResource } from '../../../types'; import { createBillingPaginatedHook } from '../createBillingPaginatedHook'; +import { createMockClerk, createMockOrganization, createMockQueryClient, createMockUser } from './mocks/clerk'; import { wrapper } from './wrapper'; // Mocks for contexts -let mockUser: any = { id: 'user_1' }; -let mockOrganization: any = { id: 'org_1' }; - -const mockClerk = { - loaded: true, - __unstable__environment: { - commerceSettings: { - billing: { - user: { enabled: true }, - organization: { enabled: true }, - }, - }, - }, -}; +let mockUser: any = createMockUser(); +let mockOrganization: any = createMockOrganization(); + +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ + queryClient: defaultQueryClient, +}); vi.mock('../../contexts', () => { return { @@ -52,11 +47,18 @@ const useDummyUnauth = createBillingPaginatedHook({ describe('createBillingPaginatedHook', () => { beforeEach(() => { vi.clearAllMocks(); + fetcherMock.mockImplementation(() => + Promise.resolve({ + data: [], + total_count: 0, + }), + ); mockClerk.loaded = true; mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = true; mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = true; - mockUser = { id: 'user_1' }; - mockOrganization = { id: 'org_1' }; + mockUser = createMockUser(); + mockOrganization = createMockOrganization(); + defaultQueryClient.client.clear(); }); it('fetches with default params when called with no params', async () => { @@ -158,6 +160,31 @@ describe('createBillingPaginatedHook', () => { }); }); + it('when for=organization orgId should be forwarded to fetcher (infinite mode)', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `item-${params.initialPage}-${i}` })), + total_count: 20, + }), + ); + + const { result } = renderHook( + () => useDummyAuth({ initialPage: 1, pageSize: 4, for: 'organization', infinite: true } as any), + { + wrapper, + }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + expect(fetcherMock.mock.calls[0][0]).toStrictEqual({ + initialPage: 1, + pageSize: 4, + orgId: 'org_1', + }); + expect(result.current.data.length).toBe(4); + }); + it('does not fetch in organization mode when organization billing disabled', async () => { mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; @@ -247,12 +274,16 @@ describe('createBillingPaginatedHook', () => { mockUser = null; rerender(); - // Attention: We are forcing fetcher to be executed instead of setting the key to null - // because SWR will continue to display the cached data when the key is null and `keepPreviousData` is true. - // This means that SWR will update the loading state to true even if the fetcher is not called, - // because the key changes from `{..., userId: 'user_1'}` to `{..., userId: undefined}`. - await waitFor(() => expect(result.current.isLoading).toBe(true)); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + if (__CLERK_USE_RQ__) { + expect(result.current.isLoading).toBe(false); + } else { + // Attention: We are forcing fetcher to be executed instead of setting the key to null + // because SWR will continue to display the cached data when the key is null and `keepPreviousData` is true. + // This means that SWR will update the loading state to true even if the fetcher is not called, + // because the key changes from `{..., userId: 'user_1'}` to `{..., userId: undefined}`. + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + } // Data should be cleared even with keepPreviousData: true // The key difference here vs usePagesOrInfinite test: userId in cache key changes diff --git a/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts b/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts new file mode 100644 index 00000000000..6326100297e --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts @@ -0,0 +1,63 @@ +import { QueryClient } from '@tanstack/query-core'; +import { vi } from 'vitest'; + +/** + * Shared query client configuration for tests + */ +export function createMockQueryClient() { + return { + __tag: 'clerk-rq-client' as const, + client: new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }, + }), + }; +} + +/** + * Simple mock Clerk factory with common properties + */ +export function createMockClerk(overrides: any = {}) { + const queryClient = overrides.queryClient || createMockQueryClient(); + + const mockClerk: any = { + loaded: true, + telemetry: { record: vi.fn() }, + on: vi.fn(), + off: vi.fn(), + __unstable__environment: { + commerceSettings: { + billing: { + user: { enabled: true }, + organization: { enabled: true }, + }, + }, + }, + ...overrides, + }; + + // Add query client as getter if not already set + if (!Object.getOwnPropertyDescriptor(mockClerk, '__internal_queryClient')) { + Object.defineProperty(mockClerk, '__internal_queryClient', { + get: vi.fn(() => queryClient), + configurable: true, + }); + } + + return mockClerk; +} + +export function createMockUser(overrides: any = {}) { + return { id: 'user_1', ...overrides }; +} + +export function createMockOrganization(overrides: any = {}) { + return { id: 'org_1', ...overrides }; +} diff --git a/packages/shared/src/react/hooks/__tests__/mocks/index.ts b/packages/shared/src/react/hooks/__tests__/mocks/index.ts new file mode 100644 index 00000000000..b3e5c8fb226 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/mocks/index.ts @@ -0,0 +1 @@ +export { createMockClerk, createMockOrganization, createMockQueryClient, createMockUser } from './clerk'; diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index 0bb871e4b58..2586379fa89 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -3,13 +3,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createDeferredPromise } from '../../../utils/createDeferredPromise'; import { usePagesOrInfinite } from '../usePagesOrInfinite'; +import { createMockClerk, createMockQueryClient } from './mocks/clerk'; import { wrapper } from './wrapper'; -describe('usePagesOrInfinite - basic pagination', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +const defaultQueryClient = createMockQueryClient(); +const mockClerk = createMockClerk({ + queryClient: defaultQueryClient, +}); + +vi.mock('../../contexts', () => { + return { + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useUserContext: () => ({ id: 'user_123' }), + useOrganizationContext: () => ({ organization: { id: 'org_123' } }), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; +}); + +describe('usePagesOrInfinite - basic pagination', () => { it('uses SWR with merged key and fetcher params; maps data and count', async () => { const fetcher = vi.fn(async (p: any) => { // simulate API returning paginated response @@ -277,6 +295,7 @@ describe('usePagesOrInfinite - keepPreviousData behavior', () => { () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), { wrapper }, ); + expect(result.current.isLoading).toBe(true); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }]); @@ -285,6 +304,7 @@ describe('usePagesOrInfinite - keepPreviousData behavior', () => { }); // page updated immediately, data remains previous while fetching expect(result.current.page).toBe(2); + // expect(result.current.isLoading).toBe(false); expect(result.current.isFetching).toBe(true); expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }]); @@ -293,6 +313,42 @@ describe('usePagesOrInfinite - keepPreviousData behavior', () => { await waitFor(() => expect(result.current.isFetching).toBe(false)); expect(result.current.data).toEqual([{ id: 'p2-a' }, { id: 'p2-b' }]); }); + + it('empties previous page data when fetching next page (pagination mode)', async () => { + const deferred = createDeferredPromise(); + const fetcher = vi.fn(async (p: any) => { + if (p.initialPage === 1) { + return { data: [{ id: 'p1-a' }, { id: 'p1-b' }], total_count: 4 }; + } + return deferred.promise.then(() => ({ data: [{ id: 'p2-a' }, { id: 'p2-b' }], total_count: 4 })); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, keepPreviousData: false, enabled: true } as const; + const cacheKeys = { type: 't-keepPrev' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }]); + + act(() => { + result.current.fetchNext(); + }); + // page updated immediately, data remains previous while fetching + expect(result.current.page).toBe(2); + // expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(true); + expect(result.current.data).toEqual([]); + + // resolve next page + deferred.resolve(undefined); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data).toEqual([{ id: 'p2-a' }, { id: 'p2-b' }]); + }); }); describe('usePagesOrInfinite - pagination helpers', () => { @@ -644,3 +700,180 @@ describe('usePagesOrInfinite - error propagation', () => { expect(result.current.isLoading).toBe(false); }); }); + +describe('usePagesOrInfinite - query state transitions and remounting', () => { + it('pagination mode: isLoading may briefly be true when query key changes, even with cached data', async () => { + const fetcher = vi.fn(async (p: any) => ({ + data: [{ id: `item-${p.filter}` }], + total_count: 1, + })); + + type TestParams = { initialPage: number; pageSize: number; filter: string }; + const params1: TestParams = { initialPage: 1, pageSize: 2, filter: 'A' }; + const config = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-transition-test' } as const; + + // First render with filter 'A' + const { result, rerender } = renderHook( + ({ params }: { params: TestParams }) => + usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper, initialProps: { params: params1 } }, + ); + + // Wait for initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'item-A' }]); + expect(fetcher).toHaveBeenCalledTimes(1); + + // Change query parameters (simulating tab switch or filter change) + const params2: TestParams = { initialPage: 1, pageSize: 2, filter: 'B' }; + rerender({ params: params2 }); + + // During the transition, isLoading may briefly be true as RQ processes the new query + // This is the behavior that caused the flaky test - components that conditionally + // render based on isLoading may show loading state briefly + const capturedStates: Array<{ isLoading: boolean; isFetching: boolean }> = []; + + // Capture states during transition + let iterations = 0; + while (iterations < 10 && result.current.data[0]?.id !== 'item-B') { + capturedStates.push({ + isLoading: result.current.isLoading, + isFetching: result.current.isFetching, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + iterations++; + } + + // Wait for new data to settle + await waitFor(() => expect(result.current.data).toEqual([{ id: 'item-B' }])); + expect(result.current.isLoading).toBe(false); + + // Document that during transition, we may see loading/fetching states + // This is expected RQ behavior and tests must account for it + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenCalledWith(expect.objectContaining({ filter: 'B' })); + }); + + it('pagination mode: after data loads, subsequent renders with same params keep isLoading false', async () => { + const fetcher = vi.fn(async (_p: any) => ({ + data: [{ id: 'stable' }], + total_count: 1, + })); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-stable-render' } as const; + + const { result, rerender } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // Wait for initial load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'stable' }]); + + const initialCallCount = fetcher.mock.calls.length; + + // Multiple re-renders with same params should not trigger loading state + rerender(); + expect(result.current.isLoading).toBe(false); + + rerender(); + expect(result.current.isLoading).toBe(false); + + rerender(); + expect(result.current.isLoading).toBe(false); + + // Should not have triggered additional fetches + expect(fetcher).toHaveBeenCalledTimes(initialCallCount); + expect(result.current.data).toEqual([{ id: 'stable' }]); + }); + + it('infinite mode: isLoading stays false when component re-renders after initial data load', async () => { + const fetcher = vi.fn(async (_p: any) => ({ + data: [{ id: 'inf-1' }, { id: 'inf-2' }], + total_count: 2, + })); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: true, enabled: true } as const; + const cacheKeys = { type: 't-infinite-stable' } as const; + + const { result, rerender } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // Wait for initial load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'inf-1' }, { id: 'inf-2' }]); + + // Re-render multiple times - isLoading should remain false + rerender(); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual([{ id: 'inf-1' }, { id: 'inf-2' }]); + + rerender(); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual([{ id: 'inf-1' }, { id: 'inf-2' }]); + }); + + it('documents the difference between isLoading and isFetching for test authors', async () => { + const deferred = createDeferredPromise(); + let callCount = 0; + const fetcher = vi.fn(async (_p: any) => { + callCount++; + if (callCount === 1) { + return { data: [{ id: 'first' }], total_count: 1 }; + } + return deferred.promise.then(() => ({ data: [{ id: 'second' }], total_count: 1 })); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-loading-vs-fetching' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // On initial mount: + // - isLoading: true (first fetch, no data) + // - isFetching: true (query is running) + expect(result.current.isLoading).toBe(true); + expect(result.current.isFetching).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toEqual([{ id: 'first' }]); + + // Trigger refetch + act(() => { + (result.current as any).revalidate(); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // After initial load, during refetch: + // - isLoading: false (we have data, this is not the first fetch) + // - isFetching: true (query is running) + // This is CRITICAL for test stability - components that render based on + // isLoading should not show loading state during refetches + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(true); + + // Resolve refetch + deferred.resolve(undefined); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // After refetch completes: + // - isLoading: false + // - isFetching: false + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toEqual([{ id: 'second' }]); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx index 829aacf1745..c5955a4fd46 100644 --- a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx @@ -2,8 +2,10 @@ 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' }; -const mockOrganization: any = { id: 'org_1' }; +import { createMockClerk, createMockOrganization, createMockQueryClient, createMockUser } from './mocks/clerk'; + +const mockUser: any = createMockUser(); +const mockOrganization: any = createMockOrganization(); const getPlansSpy = vi.fn((args: any) => Promise.resolve({ @@ -16,21 +18,14 @@ const getPlansSpy = vi.fn((args: any) => }), ); -const mockClerk = { - loaded: true, +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ billing: { getPlans: getPlansSpy, }, - telemetry: { record: vi.fn() }, - __unstable__environment: { - commerceSettings: { - billing: { - user: { enabled: true }, - organization: { enabled: true }, - }, - }, - }, -}; + queryClient: defaultQueryClient, +}); vi.mock('../../contexts', () => { return { @@ -51,6 +46,7 @@ describe('usePlans', () => { mockClerk.loaded = true; mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = true; mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = true; + defaultQueryClient.client.clear(); }); it('does not call fetcher when clerk.loaded is false', () => { @@ -115,6 +111,8 @@ describe('usePlans', () => { expect(getPlansSpy).toHaveBeenCalledTimes(1); // orgId must not leak to fetcher expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', pageSize: 3, initialPage: 1 }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data.length).toBe(3); }); @@ -182,4 +180,26 @@ describe('usePlans', () => { ]), ); }); + + it('does not clear data after user sign out', async () => { + const { result, rerender } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 }), { wrapper }); + + // Wait for initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + expect(result.current.data.length).toBe(5); + expect(result.current.count).toBe(25); + + const initialData = result.current.data; + + // Simulate user sign out + mockUser.id = null; + rerender(); + + // Data should persist after sign out + expect(result.current.data).toEqual(initialData); + expect(result.current.data.length).toBe(5); + expect(result.current.count).toBe(25); + }); }); diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx index e207a4c0af7..f4c5e7d0750 100644 --- a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -2,11 +2,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useSubscription } from '../useSubscription'; +import { createMockClerk, createMockOrganization, createMockQueryClient, createMockUser } from './mocks/clerk'; import { wrapper } from './wrapper'; // Dynamic mock state for contexts -let mockUser: any = { id: 'user_1' }; -let mockOrganization: any = { id: 'org_1' }; +let mockUser: any = createMockUser(); +let mockOrganization: any = createMockOrganization(); let userBillingEnabled = true; let orgBillingEnabled = true; @@ -15,13 +16,13 @@ const getSubscriptionSpy = vi.fn((args?: { orgId?: string }) => Promise.resolve({ id: args?.orgId ? `sub_org_${args.orgId}` : 'sub_user_user_1' }), ); -const mockClerk = { - loaded: true, +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ billing: { getSubscription: getSubscriptionSpy, }, - telemetry: { record: vi.fn() }, - __unstable__environment: { + environment: { commerceSettings: { billing: { user: { enabled: userBillingEnabled }, @@ -29,7 +30,8 @@ const mockClerk = { }, }, }, -}; + queryClient: defaultQueryClient, +}); vi.mock('../../contexts', () => { return { @@ -46,10 +48,11 @@ describe('useSubscription', () => { // Reset environment flags and state userBillingEnabled = true; orgBillingEnabled = true; - mockUser = { id: 'user_1' }; - mockOrganization = { id: 'org_1' }; + mockUser = createMockUser(); + mockOrganization = createMockOrganization(); mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = userBillingEnabled; mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = orgBillingEnabled; + defaultQueryClient.client.clear(); }); it('does not fetch when billing disabled for user', () => { @@ -100,11 +103,15 @@ describe('useSubscription', () => { mockUser = null; rerender(); - // Asser that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. - await waitFor(() => expect(result.current.isFetching).toBe(true)); + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(result.current.data).toBeUndefined()); + } else { + // Assert that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. + await waitFor(() => expect(result.current.isFetching).toBe(true)); + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + } - // The fetcher returns null when userId is falsy, so data should become null - await waitFor(() => expect(result.current.data).toBeNull()); expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); expect(result.current.isFetching).toBe(false); }); @@ -124,11 +131,16 @@ describe('useSubscription', () => { mockUser = null; rerender({ kp: true }); - // Asser that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. - await waitFor(() => expect(result.current.isFetching).toBe(true)); + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(result.current.data).toBeUndefined()); + } else { + // Assert that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + } - // The fetcher returns null when userId is falsy, so data should become null - await waitFor(() => expect(result.current.data).toBeNull()); expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); expect(result.current.isFetching).toBe(false); }); diff --git a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts new file mode 100644 index 00000000000..018ea82c75c --- /dev/null +++ b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts @@ -0,0 +1,26 @@ +import type { PagesOrInfiniteConfig, PagesOrInfiniteOptions, PaginatedResources } from '../types'; + +export type ArrayType = DataArray extends Array ? ElementType : never; + +export type ExtractData = Type extends { data: infer Data } ? ArrayType : Type; + +export type UsePagesOrInfiniteSignature = < + Params extends PagesOrInfiniteOptions, + FetcherReturnData extends Record, + CacheKeys extends Record = Record, + TConfig extends PagesOrInfiniteConfig = PagesOrInfiniteConfig, +>( + /** + * The parameters will be passed to the fetcher. + */ + params: Params, + /** + * A Promise returning function to fetch your data. + */ + fetcher: ((p: Params) => FetcherReturnData | Promise) | undefined, + /** + * Internal configuration of the hook. + */ + config: TConfig, + cacheKeys: CacheKeys, +) => PaginatedResources, TConfig['infinite']>; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx new file mode 100644 index 00000000000..c7a2770b037 --- /dev/null +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { ClerkPaginatedResponse } from '../../types'; +import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; +import { useClerkInfiniteQuery } from '../clerk-rq/useInfiniteQuery'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import type { CacheSetter, ValueOrSetter } from '../types'; +import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; +import { getDifferentKeys, useWithSafeValues } from './usePagesOrInfinite.shared'; +import { usePreviousValue } from './usePreviousValue'; + +export const usePagesOrInfinite: UsePagesOrInfiniteSignature = (params, fetcher, config, cacheKeys) => { + const [paginatedPage, setPaginatedPage] = useState(params.initialPage ?? 1); + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(params.initialPage ?? 1); + const pageSizeRef = useRef(params.pageSize ?? 10); + + const enabled = config.enabled ?? true; + const isSignedIn = config.isSignedIn; + const triggerInfinite = config.infinite ?? false; + const cacheMode = config.__experimental_mode === 'cache'; + const keepPreviousData = config.keepPreviousData ?? false; + + const [queryClient] = useClerkQueryClient(); + + // Compute the actual enabled state for queries (considering all conditions) + const queriesEnabled = enabled && Boolean(fetcher) && !cacheMode && isSignedIn !== false; + + // Force re-render counter for cache-only updates + const [forceUpdateCounter, setForceUpdateCounter] = useState(0); + const forceUpdate = useCallback((updater: (n: number) => number) => { + setForceUpdateCounter(updater); + }, []); + + // Non-infinite mode: single page query + const pagesQueryKey = useMemo(() => { + return [ + 'clerk-pages', + { + ...cacheKeys, + ...params, + initialPage: paginatedPage, + pageSize: pageSizeRef.current, + }, + ]; + }, [cacheKeys, params, paginatedPage]); + + const singlePageQuery = useClerkQuery({ + queryKey: pagesQueryKey, + queryFn: ({ queryKey }) => { + const [, key] = queryKey as [string, Record]; + + if (!fetcher) { + return undefined as any; + } + + const requestParams = getDifferentKeys(key, cacheKeys); + + // @ts-ignore - params type differs slightly but is structurally compatible + return fetcher({ ...params, ...requestParams } as Params); + }, + staleTime: 60_000, + enabled: queriesEnabled && !triggerInfinite, + // Use placeholderData to keep previous data while fetching new page + placeholderData: keepPreviousData ? previousData => previousData : undefined, + }); + + // Infinite mode: accumulate pages + const infiniteQueryKey = useMemo(() => { + return [ + 'clerk-pages-infinite', + { + ...cacheKeys, + ...params, + }, + ]; + }, [cacheKeys, params]); + + const infiniteQuery = useClerkInfiniteQuery>({ + queryKey: infiniteQueryKey, + initialPageParam: params.initialPage ?? 1, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + const total = lastPage?.total_count ?? 0; + const consumed = (allPages.length + (params.initialPage ? params.initialPage - 1 : 0)) * (params.pageSize ?? 10); + return consumed < total ? (lastPageParam as number) + 1 : undefined; + }, + queryFn: ({ pageParam }) => { + if (!fetcher) { + return undefined as any; + } + // @ts-ignore - merging page params for fetcher call + return fetcher({ ...params, initialPage: pageParam, pageSize: pageSizeRef.current } as Params); + }, + staleTime: 60_000, + enabled: queriesEnabled && triggerInfinite, + }); + + // Track previous isSignedIn state to detect sign-out transitions + const previousIsSignedIn = usePreviousValue(isSignedIn); + + // Detect sign-out and clear queries + useEffect(() => { + const isNowSignedOut = isSignedIn === false; + + if (previousIsSignedIn && isNowSignedOut) { + // Clear ALL queries matching the base query keys (including old userId) + // Use predicate to match queries that start with 'clerk-pages' or 'clerk-pages-infinite' + + queryClient.removeQueries({ + predicate: query => { + const key = query.queryKey; + return ( + (Array.isArray(key) && key[0] === 'clerk-pages') || + (Array.isArray(key) && key[0] === 'clerk-pages-infinite') + ); + }, + }); + + // Reset paginated page to initial + setPaginatedPage(initialPageRef.current); + + // Force re-render to reflect cache changes + void Promise.resolve().then(() => forceUpdate(n => n + 1)); + } + }, [isSignedIn, queryClient, previousIsSignedIn, forceUpdate]); + + const page = useMemo(() => { + if (triggerInfinite) { + // Read from query data first, fallback to cache + const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); + const pages = infiniteQuery.data?.pages ?? cachedData?.pages ?? []; + // Return pages.length if > 0, otherwise return initialPage (default 1) + return pages.length > 0 ? pages.length : initialPageRef.current; + } + return paginatedPage; + }, [triggerInfinite, infiniteQuery.data?.pages, paginatedPage, queryClient, infiniteQueryKey]); + + const fetchPage: ValueOrSetter = useCallback( + numberOrgFn => { + if (triggerInfinite) { + const next = typeof numberOrgFn === 'function' ? (numberOrgFn as (n: number) => number)(page) : numberOrgFn; + const targetCount = Math.max(0, next); + const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); + const pages = infiniteQuery.data?.pages ?? cachedData?.pages ?? []; + const currentCount = pages.length; + const toFetch = targetCount - currentCount; + if (toFetch > 0) { + void infiniteQuery.fetchNextPage({ cancelRefetch: false }); + } + return; + } + return setPaginatedPage(numberOrgFn); + }, + [infiniteQuery, page, triggerInfinite, queryClient, infiniteQueryKey], + ); + + const data = useMemo(() => { + if (triggerInfinite) { + const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); + // When query is disabled, the hook's data is stale, so only read from cache + const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); + return pages.map((a: ClerkPaginatedResponse) => a?.data).flat() ?? []; + } + + // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache + // This ensures that after cache clearing, we return empty data + const pageData = queriesEnabled + ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) + : queryClient.getQueryData>(pagesQueryKey); + return pageData?.data ?? []; + }, [ + queriesEnabled, + forceUpdateCounter, + triggerInfinite, + singlePageQuery.data, + infiniteQuery.data, + queryClient, + pagesQueryKey, + infiniteQueryKey, + ]); + + const count = useMemo(() => { + if (triggerInfinite) { + const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); + // When query is disabled, the hook's data is stale, so only read from cache + const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); + return pages[pages.length - 1]?.total_count || 0; + } + + // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache + // This ensures that after cache clearing, we return 0 + const pageData = queriesEnabled + ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) + : queryClient.getQueryData>(pagesQueryKey); + return pageData?.total_count ?? 0; + }, [ + queriesEnabled, + forceUpdateCounter, + triggerInfinite, + singlePageQuery.data, + infiniteQuery.data, + queryClient, + pagesQueryKey, + infiniteQueryKey, + ]); + + const isLoading = triggerInfinite ? infiniteQuery.isLoading : singlePageQuery.isLoading; + const isFetching = triggerInfinite ? infiniteQuery.isFetching : singlePageQuery.isFetching; + const error = (triggerInfinite ? (infiniteQuery.error as any) : singlePageQuery.error) ?? null; + const isError = !!error; + + const fetchNext = useCallback(() => { + if (triggerInfinite) { + void infiniteQuery.fetchNextPage({ cancelRefetch: false }); + return; + } + setPaginatedPage(n => Math.max(0, n + 1)); + }, [infiniteQuery, triggerInfinite]); + + const fetchPrevious = useCallback(() => { + if (triggerInfinite) { + // not natively supported by forward-only pagination; noop + return; + } + setPaginatedPage(n => Math.max(0, n - 1)); + }, [triggerInfinite]); + + const offsetCount = (initialPageRef.current - 1) * pageSizeRef.current; + const pageCount = Math.ceil((count - offsetCount) / pageSizeRef.current); + const hasNextPage = triggerInfinite + ? Boolean(infiniteQuery.hasNextPage) + : count - offsetCount * pageSizeRef.current > page * pageSizeRef.current; + const hasPreviousPage = triggerInfinite + ? Boolean(infiniteQuery.hasPreviousPage) + : (page - 1) * pageSizeRef.current > offsetCount * pageSizeRef.current; + + const setData: CacheSetter = value => { + if (triggerInfinite) { + queryClient.setQueryData(infiniteQueryKey, (prevValue: any = {}) => { + const prevPages = Array.isArray(prevValue?.pages) ? prevValue.pages : []; + const nextPages = (typeof value === 'function' ? value(prevPages) : value) as Array< + ClerkPaginatedResponse + >; + return { ...prevValue, pages: nextPages }; + }); + // Force re-render to reflect cache changes + forceUpdate(n => n + 1); + return Promise.resolve(); + } + queryClient.setQueryData(pagesQueryKey, (prevValue: any = { data: [], total_count: 0 }) => { + const nextValue = (typeof value === 'function' ? value(prevValue) : value) as ClerkPaginatedResponse; + return nextValue; + }); + // Force re-render to reflect cache changes + forceUpdate(n => n + 1); + return Promise.resolve(); + }; + + const revalidate = () => { + if (triggerInfinite) { + return queryClient.invalidateQueries({ queryKey: infiniteQueryKey }); + } + return queryClient.invalidateQueries({ queryKey: pagesQueryKey }); + }; + + return { + data, + count, + error, + isLoading, + isFetching, + isError, + page, + pageCount, + fetchPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + revalidate: revalidate as any, + setData: setData as any, + }; +}; + +export { useWithSafeValues }; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts new file mode 100644 index 00000000000..d89696be360 --- /dev/null +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts @@ -0,0 +1,89 @@ +'use client'; + +import { useRef } from 'react'; + +import type { PagesOrInfiniteOptions } from '../types'; + +/** + * A hook that safely merges user-provided pagination options with default values. + * It caches initial pagination values (page and size) until component unmount to prevent unwanted rerenders. + * + * @internal + * + * @example + * ```typescript + * // Example 1: With user-provided options + * const userOptions = { initialPage: 2, pageSize: 20, infinite: true }; + * const defaults = { initialPage: 1, pageSize: 10, infinite: false }; + * useWithSafeValues(userOptions, defaults); + * // Returns { initialPage: 2, pageSize: 20, infinite: true } + * + * // Example 2: With boolean true (use defaults) + * const params = true; + * const defaults = { initialPage: 1, pageSize: 10, infinite: false }; + * useWithSafeValues(params, defaults); + * // Returns { initialPage: 1, pageSize: 10, infinite: false } + * + * // Example 3: With undefined options (fallback to defaults) + * const params = undefined; + * const defaults = { initialPage: 1, pageSize: 10, infinite: false }; + * useWithSafeValues(params, defaults); + * // Returns { initialPage: 1, pageSize: 10, infinite: false } + * ``` + */ +export const useWithSafeValues = (params: T | true | undefined, defaultValues: T) => { + const shouldUseDefaults = typeof params === 'boolean' && params; + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef( + shouldUseDefaults ? defaultValues.initialPage : (params?.initialPage ?? defaultValues.initialPage), + ); + const pageSizeRef = useRef(shouldUseDefaults ? defaultValues.pageSize : (params?.pageSize ?? defaultValues.pageSize)); + + const newObj: Record = {}; + for (const key of Object.keys(defaultValues)) { + // @ts-ignore - indexing into generic param to preserve unknown keys from defaults/params + newObj[key] = shouldUseDefaults ? defaultValues[key] : (params?.[key] ?? defaultValues[key]); + } + + return { + ...newObj, + initialPage: initialPageRef.current, + pageSize: pageSizeRef.current, + } as T; +}; + +/** + * Returns an object containing only the keys from the first object that are not present in the second object. + * Useful for extracting unique parameters that should be passed to a request while excluding common cache keys. + * + * @internal + * + * @example + * ```typescript + * // Example 1: Basic usage + * const obj1 = { name: 'John', age: 30, city: 'NY' }; + * const obj2 = { name: 'John', age: 30 }; + * getDifferentKeys(obj1, obj2); // Returns { city: 'NY' } + * + * // Example 2: With cache keys + * const requestParams = { page: 1, limit: 10, userId: '123' }; + * const cacheKeys = { userId: '123' }; + * getDifferentKeys(requestParams, cacheKeys); // Returns { page: 1, limit: 10 } + * ``` + */ +export function getDifferentKeys( + obj1: Record, + obj2: Record, +): Record { + const keysSet = new Set(Object.keys(obj2)); + const differentKeysObject: Record = {}; + + for (const key1 of Object.keys(obj1)) { + if (!keysSet.has(key1)) { + differentKeysObject[key1] = obj1[key1]; + } + } + + return differentKeysObject; +} diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx similarity index 65% rename from packages/shared/src/react/hooks/usePagesOrInfinite.ts rename to packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx index 45abe5317ff..e23a173835e 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx @@ -3,96 +3,11 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useSWR, useSWRInfinite } from '../clerk-swr'; -import type { - CacheSetter, - PagesOrInfiniteConfig, - PagesOrInfiniteOptions, - PaginatedResources, - ValueOrSetter, -} from '../types'; +import type { CacheSetter, ValueOrSetter } from '../types'; +import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; +import { getDifferentKeys, useWithSafeValues } from './usePagesOrInfinite.shared'; import { usePreviousValue } from './usePreviousValue'; -/** - * Returns an object containing only the keys from the first object that are not present in the second object. - * Useful for extracting unique parameters that should be passed to a request while excluding common cache keys. - * - * @internal - * - * @example - * ```typescript - * // Example 1: Basic usage - * const obj1 = { name: 'John', age: 30, city: 'NY' }; - * const obj2 = { name: 'John', age: 30 }; - * getDifferentKeys(obj1, obj2); // Returns { city: 'NY' } - * - * // Example 2: With cache keys - * const requestParams = { page: 1, limit: 10, userId: '123' }; - * const cacheKeys = { userId: '123' }; - * getDifferentKeys(requestParams, cacheKeys); // Returns { page: 1, limit: 10 } - * ``` - */ -function getDifferentKeys(obj1: Record, obj2: Record): Record { - const keysSet = new Set(Object.keys(obj2)); - const differentKeysObject: Record = {}; - - for (const key1 of Object.keys(obj1)) { - if (!keysSet.has(key1)) { - differentKeysObject[key1] = obj1[key1]; - } - } - - return differentKeysObject; -} - -/** - * A hook that safely merges user-provided pagination options with default values. - * It caches initial pagination values (page and size) until component unmount to prevent unwanted rerenders. - * - * @internal - * - * @example - * ```typescript - * // Example 1: With user-provided options - * const userOptions = { initialPage: 2, pageSize: 20, infinite: true }; - * const defaults = { initialPage: 1, pageSize: 10, infinite: false }; - * useWithSafeValues(userOptions, defaults); - * // Returns { initialPage: 2, pageSize: 20, infinite: true } - * - * // Example 2: With boolean true (use defaults) - * const params = true; - * const defaults = { initialPage: 1, pageSize: 10, infinite: false }; - * useWithSafeValues(params, defaults); - * // Returns { initialPage: 1, pageSize: 10, infinite: false } - * - * // Example 3: With undefined options (fallback to defaults) - * const params = undefined; - * const defaults = { initialPage: 1, pageSize: 10, infinite: false }; - * useWithSafeValues(params, defaults); - * // Returns { initialPage: 1, pageSize: 10, infinite: false } - * ``` - */ -export const useWithSafeValues = (params: T | true | undefined, defaultValues: T) => { - const shouldUseDefaults = typeof params === 'boolean' && params; - - // Cache initialPage and initialPageSize until unmount - const initialPageRef = useRef( - shouldUseDefaults ? defaultValues.initialPage : (params?.initialPage ?? defaultValues.initialPage), - ); - const pageSizeRef = useRef(shouldUseDefaults ? defaultValues.pageSize : (params?.pageSize ?? defaultValues.pageSize)); - - const newObj: Record = {}; - for (const key of Object.keys(defaultValues)) { - // @ts-ignore - defaultValues and params share shape; dynamic index access is safe here - newObj[key] = shouldUseDefaults ? defaultValues[key] : (params?.[key] ?? defaultValues[key]); - } - - return { - ...newObj, - initialPage: initialPageRef.current, - pageSize: pageSizeRef.current, - } as T; -}; - const cachingSWROptions = { dedupingInterval: 1000 * 60, focusThrottleInterval: 1000 * 60 * 2, @@ -103,30 +18,6 @@ const cachingSWRInfiniteOptions = { revalidateFirstPage: false, } satisfies Parameters[2]; -type ArrayType = DataArray extends Array ? ElementType : never; -type ExtractData = Type extends { data: infer Data } ? ArrayType : Type; - -type UsePagesOrInfinite = < - Params extends PagesOrInfiniteOptions, - FetcherReturnData extends Record, - CacheKeys extends Record = Record, - TConfig extends PagesOrInfiniteConfig = PagesOrInfiniteConfig, ->( - /** - * The parameters will be passed to the fetcher. - */ - params: Params, - /** - * A Promise returning function to fetch your data. - */ - fetcher: ((p: Params) => FetcherReturnData | Promise) | undefined, - /** - * Internal configuration of the hook. - */ - config: TConfig, - cacheKeys: CacheKeys, -) => PaginatedResources, TConfig['infinite']>; - /** * A flexible pagination hook that supports both traditional pagination and infinite loading. * It provides a unified API for handling paginated data fetching, with built-in caching through SWR. @@ -143,7 +34,7 @@ type UsePagesOrInfinite = < * * @internal */ -export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, cacheKeys) => { +export const usePagesOrInfinite: UsePagesOrInfiniteSignature = (params, fetcher, config, cacheKeys) => { const [paginatedPage, setPaginatedPage] = useState(params.initialPage ?? 1); // Cache initialPage and initialPageSize until unmount @@ -252,7 +143,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, // @ts-ignore - remove cache-only keys from request params const requestParams = getDifferentKeys(cacheKeyParams, cacheKeys); // @ts-ignore - fetcher expects Params subset; narrowing at call-site - return fetcher?.(requestParams); + return fetcher?.({ ...params, ...requestParams }); }, cachingSWRInfiniteOptions, ); @@ -293,9 +184,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, const isFetching = triggerInfinite ? swrInfiniteIsValidating : swrIsValidating; const error = (triggerInfinite ? swrInfiniteError : swrError) ?? null; const isError = !!error; - /** - * Helpers. - */ + const fetchNext = useCallback(() => { fetchPage(n => Math.max(0, n + 1)); }, [fetchPage]); @@ -342,3 +231,5 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, setData: setData as any, }; }; + +export { useWithSafeValues }; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.tsx new file mode 100644 index 00000000000..3bb9fe522ff --- /dev/null +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.tsx @@ -0,0 +1,2 @@ +export { usePagesOrInfinite } from 'virtual:data-hooks/usePagesOrInfinite'; +export { useWithSafeValues } from './usePagesOrInfinite.shared'; diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx index f355e6cb4f6..f5af4c27acf 100644 --- a/packages/shared/src/react/hooks/useSubscription.rq.tsx +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -57,6 +57,7 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes }, staleTime: 1_000 * 60, enabled: Boolean(user?.id && billingEnabled) && ((params as any)?.enabled ?? true), + // TODO(@RQ_MIGRATION): Add support for keepPreviousData }); const revalidate = useCallback(() => queryClient.invalidateQueries({ queryKey }), [queryClient, queryKey]); diff --git a/packages/shared/src/types/virtual-data-hooks.d.ts b/packages/shared/src/types/virtual-data-hooks.d.ts index 0f3065af451..141a75a01ce 100644 --- a/packages/shared/src/types/virtual-data-hooks.d.ts +++ b/packages/shared/src/types/virtual-data-hooks.d.ts @@ -2,6 +2,7 @@ declare module 'virtual:data-hooks/*' { // Generic export signatures to satisfy type resolution for virtual modules export const SWRConfigCompat: any; export const useSubscription: any; + export const usePagesOrInfinite: any; const mod: any; export default mod; } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 07df0392194..6a0663ee028 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -24,7 +24,8 @@ "allowJs": true, "paths": { "virtual:data-hooks/useSubscription": ["./src/react/hooks/useSubscription.swr.tsx"], - "virtual:data-hooks/SWRConfigCompat": ["./src/react/providers/SWRConfigCompat.swr.tsx"] + "virtual:data-hooks/SWRConfigCompat": ["./src/react/providers/SWRConfigCompat.swr.tsx"], + "virtual:data-hooks/usePagesOrInfinite": ["./src/react/hooks/usePagesOrInfinite.swr.tsx"] } }, "exclude": ["node_modules"], diff --git a/packages/shared/vitest.setup.mts b/packages/shared/vitest.setup.mts index 4a7eab9d28d..cd7485c1c61 100644 --- a/packages/shared/vitest.setup.mts +++ b/packages/shared/vitest.setup.mts @@ -7,6 +7,7 @@ globalThis.__DEV__ = true; globalThis.PACKAGE_NAME = '@clerk/clerk-react'; globalThis.PACKAGE_VERSION = '0.0.0-test'; globalThis.JS_PACKAGE_VERSION = '5.0.0'; +globalThis.__CLERK_USE_RQ__ = process.env.CLERK_USE_RQ === 'true'; // Setup Web Crypto API for tests (Node.js 18+ compatibility) if (!globalThis.crypto) {