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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-shoes-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/vue": patch
---

Fixed an error occurring in the composables where watchers attempted to call unwatch() within their own initialization.
5 changes: 4 additions & 1 deletion packages/vue/src/composables/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import { useClerkContext } from './useClerkContext';
*/
function clerkLoaded(clerk: ShallowRef<Clerk | null>) {
return new Promise<Clerk>(resolve => {
watch(
let unwatch: (() => void) | undefined;
// eslint-disable-next-line prefer-const
unwatch = watch(
clerk,
value => {
if (value?.loaded) {
resolve(value);
unwatch?.();
}
},
{ immediate: true },
Expand Down
5 changes: 2 additions & 3 deletions packages/vue/src/composables/useOrganization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const useOrganization: UseOrganization = () => {
const { clerk, organizationCtx } = useClerkContext('useOrganization');
const { session } = useSession();

const unwatch = watch(
watch(
clerk,
value => {
if (value) {
Expand All @@ -65,10 +65,9 @@ export const useOrganization: UseOrganization = () => {
for: 'organizations',
caller: 'useOrganization',
});
unwatch();
}
},
{ immediate: true },
{ once: true },
);

const result = computed<UseOrganizationReturn>(() => {
Expand Down
15 changes: 9 additions & 6 deletions packages/vue/src/composables/useSignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ type UseSignIn = () => ToComputedRefs<UseSignInReturn>;
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 },
);
Comment on lines +35 to +43
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why not use the useClerkLoaded helper?

A: Telemetry can be recorded as soon as the clerk instance exists. We don't have to wait for Clerk to be loaded


const result = computed<UseSignInReturn>(() => {
if (!clerk.value || !clientCtx.value) {
Expand Down
15 changes: 9 additions & 6 deletions packages/vue/src/composables/useSignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ type UseSignUp = () => ToComputedRefs<UseSignUpReturn>;
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<UseSignUpReturn>(() => {
if (!clerk.value || !clientCtx.value) {
Expand Down
151 changes: 151 additions & 0 deletions packages/vue/src/utils/__tests__/useClerkLoaded.test.ts
Original file line number Diff line number Diff line change
@@ -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<Clerk | null>;
let mockClerk: Partial<Clerk>;

beforeEach(() => {
clerkRef = ref(null) as ShallowRef<Clerk | null>;
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();
});
});
5 changes: 4 additions & 1 deletion packages/vue/src/utils/useClerkLoaded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ 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) {
return;
}

callback(unwrappedClerk as LoadedClerk);
unwatch?.();
},
{ immediate: true },
Comment on lines +20 to 32
Copy link
Copy Markdown
Member Author

@wobsoriano wobsoriano Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not using the once option here as we also need to wait for the loaded value of Clerk to be true and the once option will stop the watcher once clerk has a value

);
Expand Down