diff --git a/.changeset/red-shoes-drum.md b/.changeset/red-shoes-drum.md new file mode 100644 index 00000000000..09752718a21 --- /dev/null +++ b/.changeset/red-shoes-drum.md @@ -0,0 +1,5 @@ +--- +"@clerk/vue": patch +--- + +Fixed an error occurring in the composables where watchers attempted to call unwatch() within their own initialization. diff --git a/packages/vue/src/composables/useAuth.ts b/packages/vue/src/composables/useAuth.ts index b7f9ff67ae9..a63e00aba7f 100644 --- a/packages/vue/src/composables/useAuth.ts +++ b/packages/vue/src/composables/useAuth.ts @@ -13,11 +13,14 @@ import { useClerkContext } from './useClerkContext'; */ function clerkLoaded(clerk: ShallowRef) { return new Promise(resolve => { - watch( + let unwatch: (() => void) | undefined; + // eslint-disable-next-line prefer-const + unwatch = watch( clerk, value => { if (value?.loaded) { resolve(value); + unwatch?.(); } }, { immediate: true }, diff --git a/packages/vue/src/composables/useOrganization.ts b/packages/vue/src/composables/useOrganization.ts index 7310cae8add..5545b284e9c 100644 --- a/packages/vue/src/composables/useOrganization.ts +++ b/packages/vue/src/composables/useOrganization.ts @@ -56,7 +56,7 @@ export const useOrganization: UseOrganization = () => { const { clerk, organizationCtx } = useClerkContext('useOrganization'); const { session } = useSession(); - const unwatch = watch( + watch( clerk, value => { if (value) { @@ -65,10 +65,9 @@ export const useOrganization: UseOrganization = () => { for: 'organizations', caller: 'useOrganization', }); - unwatch(); } }, - { immediate: true }, + { once: true }, ); const result = computed(() => { diff --git a/packages/vue/src/composables/useSignIn.ts b/packages/vue/src/composables/useSignIn.ts index 4931829c592..b2af94dfa0a 100644 --- a/packages/vue/src/composables/useSignIn.ts +++ b/packages/vue/src/composables/useSignIn.ts @@ -32,12 +32,15 @@ type UseSignIn = () => ToComputedRefs; export const useSignIn: UseSignIn = () => { const { clerk, clientCtx } = useClerkContext('useSignIn'); - const unwatch = watch(clerk, value => { - if (value) { - value.telemetry?.record(eventMethodCalled('useSignIn')); - unwatch(); - } - }); + watch( + clerk, + value => { + if (value) { + value.telemetry?.record(eventMethodCalled('useSignIn')); + } + }, + { once: true }, + ); const result = computed(() => { if (!clerk.value || !clientCtx.value) { diff --git a/packages/vue/src/composables/useSignUp.ts b/packages/vue/src/composables/useSignUp.ts index 097a1cada60..8596f990d1f 100644 --- a/packages/vue/src/composables/useSignUp.ts +++ b/packages/vue/src/composables/useSignUp.ts @@ -32,12 +32,15 @@ type UseSignUp = () => ToComputedRefs; export const useSignUp: UseSignUp = () => { const { clerk, clientCtx } = useClerkContext('useSignUp'); - const unwatch = watch(clerk, value => { - if (value) { - value.telemetry?.record(eventMethodCalled('useSignUp')); - unwatch(); - } - }); + watch( + clerk, + value => { + if (value) { + value.telemetry?.record(eventMethodCalled('useSignUp')); + } + }, + { once: true }, + ); const result = computed(() => { if (!clerk.value || !clientCtx.value) { diff --git a/packages/vue/src/utils/__tests__/useClerkLoaded.test.ts b/packages/vue/src/utils/__tests__/useClerkLoaded.test.ts new file mode 100644 index 00000000000..97ad0805793 --- /dev/null +++ b/packages/vue/src/utils/__tests__/useClerkLoaded.test.ts @@ -0,0 +1,151 @@ +import type { Clerk } from '@clerk/shared/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, ref, type ShallowRef } from 'vue'; + +import * as composables from '../../composables'; +import { useClerkLoaded } from '../useClerkLoaded'; + +// Mock the useClerk composable +vi.mock('../../composables', () => ({ + useClerk: vi.fn(), +})); + +describe('useClerkLoaded', () => { + let clerkRef: ShallowRef; + let mockClerk: Partial; + + beforeEach(() => { + clerkRef = ref(null) as ShallowRef; + mockClerk = { + loaded: false, + }; + vi.mocked(composables.useClerk).mockReturnValue(clerkRef); + }); + + it('should not call callback when clerk is null', async () => { + const callback = vi.fn(); + + // Call useClerkLoaded + useClerkLoaded(callback); + + await nextTick(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not call callback when clerk exists but not loaded', async () => { + const callback = vi.fn(); + + // Call useClerkLoaded + useClerkLoaded(callback); + + // Set clerk instance but not loaded + clerkRef.value = { ...mockClerk, loaded: false } as Clerk; + + await nextTick(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should call callback when clerk becomes loaded', async () => { + const callback = vi.fn(); + + // Call useClerkLoaded + useClerkLoaded(callback); + + // Set clerk instance as loaded + const loadedClerk = { ...mockClerk, loaded: true } as Clerk; + clerkRef.value = loadedClerk; + + await nextTick(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(loadedClerk); + }); + + it('should call callback immediately if clerk is already loaded', async () => { + const callback = vi.fn(); + + // Set clerk instance as loaded before calling useClerkLoaded + const loadedClerk = { ...mockClerk, loaded: true } as Clerk; + clerkRef.value = loadedClerk; + + // Call useClerkLoaded + useClerkLoaded(callback); + + await nextTick(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(loadedClerk); + }); + + it('should only call callback once even if clerk updates multiple times', async () => { + const callback = vi.fn(); + + // Call useClerkLoaded + useClerkLoaded(callback); + + // Set clerk instance as loaded + const loadedClerk1 = { ...mockClerk, loaded: true, client: { id: 'client_1' } } as Clerk; + clerkRef.value = loadedClerk1; + + await nextTick(); + + expect(callback).toHaveBeenCalledOnce(); + + // Update clerk instance again (simulating a resource update) + const loadedClerk2 = { ...mockClerk, loaded: true, client: { id: 'client_2' } } as Clerk; + clerkRef.value = loadedClerk2; + + await nextTick(); + + // Should still only be called once due to unwatch() + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(loadedClerk1); + }); + + it('should handle transition from null -> not loaded -> loaded', async () => { + const callback = vi.fn(); + + // Call useClerkLoaded + useClerkLoaded(callback); + + // Initial state: null + expect(callback).not.toHaveBeenCalled(); + + // Clerk instance created but not loaded + clerkRef.value = { ...mockClerk, loaded: false } as Clerk; + await nextTick(); + expect(callback).not.toHaveBeenCalled(); + + // Clerk becomes loaded + clerkRef.value = { ...mockClerk, loaded: true } as Clerk; + await nextTick(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should properly clean up watcher after callback is called', async () => { + const callback = vi.fn(); + + // Call useClerkLoaded + useClerkLoaded(callback); + + // Set clerk as loaded + const loadedClerk = { ...mockClerk, loaded: true } as Clerk; + clerkRef.value = loadedClerk; + + await nextTick(); + + expect(callback).toHaveBeenCalledOnce(); + + // Simulate multiple updates (watcher should be cleaned up) + for (let i = 0; i < 5; i++) { + clerkRef.value = { ...mockClerk, loaded: true, session: { id: `sess_${i}` } } as Clerk; + await nextTick(); + } + + // Should still only be called once + expect(callback).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/vue/src/utils/useClerkLoaded.ts b/packages/vue/src/utils/useClerkLoaded.ts index fd7adc0d17c..dcdee50f2ad 100644 --- a/packages/vue/src/utils/useClerkLoaded.ts +++ b/packages/vue/src/utils/useClerkLoaded.ts @@ -17,7 +17,9 @@ import { useClerk } from '../composables'; export const useClerkLoaded = (callback: (clerk: LoadedClerk) => void) => { const clerk = useClerk(); - watch( + let unwatch: (() => void) | undefined; + // eslint-disable-next-line prefer-const + unwatch = watch( clerk, unwrappedClerk => { if (!unwrappedClerk?.loaded) { @@ -25,6 +27,7 @@ export const useClerkLoaded = (callback: (clerk: LoadedClerk) => void) => { } callback(unwrappedClerk as LoadedClerk); + unwatch?.(); }, { immediate: true }, );