Skip to content
6 changes: 6 additions & 0 deletions .changeset/twelve-crabs-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

[Experimental] Signal `fetchStatus` support.
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ export const events = {
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: unknown };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
Expand All @@ -23,6 +25,7 @@ type InternalEvents = {
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
};

export const eventBus = createEventBus<InternalEvents>();
95 changes: 20 additions & 75 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClerkWebAuthnError } from '@clerk/shared/error';

Check failure on line 1 in packages/clerk-js/src/core/resources/SignIn.ts

View workflow job for this annotation

GitHub Actions / Static analysis

Run autofix to sort these imports!
import { Poller } from '@clerk/shared/poller';
import { deepCamelToSnake, deepSnakeToCamel } from '@clerk/shared/underscore';
import {
Expand Down Expand Up @@ -69,6 +69,7 @@
} from '../errors';
import { eventBus } from '../events';
import { BaseResource, UserData, Verification } from './internal';
import { runAsyncTask } from '../../utils/runAsyncTask';

export class SignIn extends BaseResource implements SignInResource {
pathRoot = '/client/sign_ins';
Expand Down Expand Up @@ -493,8 +494,6 @@
submitPassword: this.submitResetPassword.bind(this),
};

fetchStatus: 'idle' | 'fetching' = 'idle';

constructor(readonly resource: SignIn) {}

get status() {
Expand All @@ -506,8 +505,7 @@
}

async sendResetPasswordEmailCode(): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
if (!this.resource.id) {
throw new Error('Cannot reset password without a sign in.');
}
Expand All @@ -525,27 +523,16 @@
body: { emailAddressId, strategy: 'reset_password_email_code' },
action: 'prepare_first_factor',
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}

async verifyResetPasswordEmailCode({ code }: { code: string }): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
await this.resource.__internal_basePost({
body: { code, strategy: 'reset_password_email_code' },
action: 'attempt_first_factor',
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}

async submitResetPassword({
Expand All @@ -555,18 +542,12 @@
password: string;
signOutOfOtherSessions?: boolean;
}): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
await this.resource.__internal_basePost({
body: { password, signOutOfOtherSessions },
action: 'reset_password',
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}

async create(params: {
Expand All @@ -575,39 +556,26 @@
redirectUrl?: string;
actionCompleteRedirectUrl?: string;
}): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: params,
});

return { error: null };
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}
});
}

async password({ identifier, password }: { identifier?: string; password: string }): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
const previousIdentifier = this.resource.identifier;
try {
return runAsyncTask(this.resource, async () => {
const previousIdentifier = this.resource.identifier;
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: { identifier: identifier || previousIdentifier, password },
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
Comment on lines +570 to +575
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Avoid sending identifier: null; include the field only when defined

Using identifier || previousIdentifier can yield null, which will serialize as identifier: null. Prefer nullish coalescing and omit the key when undefined.

-      const previousIdentifier = this.resource.identifier;
-      await this.resource.__internal_basePost({
-        path: this.resource.pathRoot,
-        body: { identifier: identifier || previousIdentifier, password },
-      });
+      const previousIdentifier = this.resource.identifier;
+      const resolvedIdentifier = identifier ?? previousIdentifier ?? undefined;
+      await this.resource.__internal_basePost({
+        path: this.resource.pathRoot,
+        body: {
+          ...(resolvedIdentifier !== undefined ? { identifier: resolvedIdentifier } : {}),
+          password,
+        },
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const previousIdentifier = this.resource.identifier;
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: { identifier: identifier || previousIdentifier, password },
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}
return { error: null };
});
const previousIdentifier = this.resource.identifier;
const resolvedIdentifier = identifier ?? previousIdentifier ?? undefined;
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: {
...(resolvedIdentifier !== undefined ? { identifier: resolvedIdentifier } : {}),
password,
},
});
});
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/SignIn.ts around lines 569 to 574,
replace the identifier || previousIdentifier pattern (which can produce
identifier: null) with logic that uses nullish coalescing and only adds the
identifier key when defined; for example, compute const id = identifier ??
previousIdentifier and build the request body by always including password but
only adding identifier if id !== undefined (and/or !== null if you want to avoid
null explicitly), then pass that body to __internal_basePost so identifier is
omitted from the serialized payload when not defined.

}

async sendEmailCode({ email }: { email: string }): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
if (!this.resource.id) {
await this.create({ identifier: email });
}
Expand All @@ -623,27 +591,16 @@
body: { emailAddressId, strategy: 'email_code' },
action: 'prepare_first_factor',
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}

async verifyEmailCode({ code }: { code: string }): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
await this.resource.__internal_basePost({
body: { code, strategy: 'email_code' },
action: 'attempt_first_factor',
});
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}

async sso({
Expand All @@ -657,8 +614,7 @@
redirectUrl: string;
redirectUrlComplete: string;
}): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
if (flow !== 'auto') {
throw new Error('modal flow is not supported yet');
}
Expand All @@ -678,27 +634,16 @@
if (status === 'unverified' && externalVerificationRedirectURL) {
windowNavigate(externalVerificationRedirectURL);
}
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}

async finalize(): Promise<{ error: unknown }> {
eventBus.emit('resource:error', { resource: this.resource, error: null });
try {
return runAsyncTask(this.resource, async () => {
if (!this.resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

await SignIn.clerk.setActive({ session: this.resource.createdSessionId });
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}

return { error: null };
});
}
}
12 changes: 5 additions & 7 deletions packages/clerk-js/src/core/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,18 @@ import { computed, signal } from 'alien-signals';

import type { SignIn } from './resources/SignIn';

export const signInSignal = signal<{ resource: SignIn | null }>({ resource: null });
export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Rename to signInResourceSignal is clear; verify callers and mark as internal

Good separation of responsibilities by renaming to signInResourceSignal. If this is not part of the public API, add an @internal JSDoc to comply with docs guidelines and prevent accidental external usage. Also, please verify there are no lingering imports/usages of the old signInSignal.

Run this to ensure no leftover references exist:

Apply this diff to mark the export as internal:

+/** @internal */
 export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Find any remaining references to the old name
rg -n --heading '\bsignInSignal\b' -g '!**/node_modules/**'

Length of output: 365


Update leftover signInSignal references & mark new export as internal

We’ve got three remaining signInSignal references that need to be switched over (and the new export marked @internal):

  • packages/clerk-js/src/core/signals.ts

    +/** @internal */
     export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
  • packages/clerk-js/src/core/state.ts (line 13)

    -export const signInSignal = signInComputedSignal;
    +export const signInResourceSignal = signInComputedSignal;
  • packages/types/src/state.ts (line 32)

    -signInSignal: Signal<SignIn | null>;
    +signInResourceSignal: Signal<SignIn | null>;
  • packages/react/src/hooks/useClerkSignal.ts (lines 21 & 38)

    -clerk.__internal_state!.signInSignal();
    +clerk.__internal_state!.signInResourceSignal();
    
    -return clerk.__internal_state.signInSignal();
    +return clerk.__internal_state.signInResourceSignal();

Please make these changes to fully remove the old signInSignal and ensure the new export is marked @internal.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
/** @internal */
export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/signals.ts around line 7, mark the new export
signInResourceSignal as internal by adding a JSDoc @internal comment immediately
above its declaration; then update the three remaining references of the old
signInSignal to the new name across the codebase: change usages in
packages/clerk-js/src/core/state.ts (line 13), packages/types/src/state.ts (line
32), and packages/react/src/hooks/useClerkSignal.ts (lines 21 and 38) to import
and use signInResourceSignal instead of signInSignal, ensuring any type
imports/exports are updated accordingly so imports resolve and there are no
leftover references to signInSignal.

export const signInErrorSignal = signal<{ error: unknown }>({ error: null });
export const signInFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' });

export const signInComputedSignal = computed(() => {
const signIn = signInSignal().resource;
const signIn = signInResourceSignal().resource;
const error = signInErrorSignal().error;
const fetchStatus = signInFetchSignal().status;

const errors = errorsToParsedErrors(error);

if (!signIn) {
return { errors, signIn: null };
}

return { errors, signIn: signIn.__internal_future };
return { errors, fetchStatus, signIn: signIn ? signIn.__internal_future : null };
});

/**
Expand Down
12 changes: 10 additions & 2 deletions packages/clerk-js/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { computed, effect } from 'alien-signals';
import { eventBus } from './events';
import type { BaseResource } from './resources/Base';
import { SignIn } from './resources/SignIn';
import { signInComputedSignal, signInErrorSignal, signInSignal } from './signals';
import { signInComputedSignal, signInErrorSignal, signInFetchSignal, signInResourceSignal } from './signals';

export class State implements StateInterface {
signInResourceSignal = signInSignal;
signInResourceSignal = signInResourceSignal;
signInErrorSignal = signInErrorSignal;
signInFetchSignal = signInFetchSignal;
signInSignal = signInComputedSignal;

__internal_effect = effect;
Expand All @@ -17,6 +18,7 @@ export class State implements StateInterface {
constructor() {
eventBus.on('resource:update', this.onResourceUpdated);
eventBus.on('resource:error', this.onResourceError);
eventBus.on('resource:fetch', this.onResourceFetch);
}

private onResourceError = (payload: { resource: BaseResource; error: unknown }) => {
Expand All @@ -30,4 +32,10 @@ export class State implements StateInterface {
this.signInResourceSignal({ resource: payload.resource });
}
};

private onResourceFetch = (payload: { resource: BaseResource; status: 'idle' | 'fetching' }) => {
if (payload.resource instanceof SignIn) {
this.signInFetchSignal({ status: payload.status });
}
};
}
65 changes: 65 additions & 0 deletions packages/clerk-js/src/utils/__tests__/runAsyncTask.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { eventBus } from '../../core/events';
import { runAsyncTask } from '../runAsyncTask';

describe('runAsyncTask', () => {
afterEach(() => {
vi.restoreAllMocks();
});

const resource = {} as any; // runAsyncTask doesn't depend on resource being a BaseResource

it('emits fetching/idle and returns result on success', async () => {
const emitSpy = vi.spyOn(eventBus, 'emit');
const task = vi.fn().mockResolvedValue('ok');

const { result, error } = await runAsyncTask(resource, task);

expect(task).toHaveBeenCalledTimes(1);
expect(result).toBe('ok');
expect(error).toBeNull();

expect(emitSpy).toHaveBeenNthCalledWith(1, 'resource:error', {
resource,
error: null,
});
expect(emitSpy).toHaveBeenNthCalledWith(2, 'resource:fetch', {
resource,
status: 'fetching',
});
expect(emitSpy).toHaveBeenNthCalledWith(3, 'resource:fetch', {
resource,
status: 'idle',
});
});

it('emits error and returns error on failure', async () => {
const emitSpy = vi.spyOn(eventBus, 'emit');
const thrown = new Error('fail');
const task = vi.fn().mockRejectedValue(thrown);

const { result, error } = await runAsyncTask(resource, task);

expect(task).toHaveBeenCalledTimes(1);
expect(result).toBeUndefined();
expect(error).toBe(thrown);

expect(emitSpy).toHaveBeenNthCalledWith(1, 'resource:error', {
resource,
error: null,
});
expect(emitSpy).toHaveBeenNthCalledWith(2, 'resource:fetch', {
resource,
status: 'fetching',
});
expect(emitSpy).toHaveBeenNthCalledWith(3, 'resource:error', {
resource,
error: thrown,
});
expect(emitSpy).toHaveBeenNthCalledWith(4, 'resource:fetch', {
resource,
status: 'idle',
});
});
});
Copy link
Contributor

@coderabbitai coderabbitai bot Aug 14, 2025

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Add a concurrency test to catch in-flight status race conditions

If two tasks run concurrently for the same resource, the current approach can emit idle while another task is still fetching, leading to flickering fetchStatus. Add a test that runs two overlapping tasks and asserts that resource:fetch does not flip to idle until both complete (or decide on desired semantics).

Example test to add:

it('handles overlapping tasks without prematurely emitting idle', async () => {
  const emitSpy = vi.spyOn(eventBus, 'emit');
  let resolveA: (v: unknown) => void;
  const taskA = vi.fn(() => new Promise(res => (resolveA = res)));
  const taskB = vi.fn().mockResolvedValue('ok');

  // Start A (fetching)
  const pA = runAsyncTask(resource, taskA);

  // Start B (still fetching)
  const pB = runAsyncTask(resource, taskB);

  // Resolve B first
  await pB;

  // At this point, we expect no final 'idle' yet if A is still in flight
  // Adjust assertion based on desired semantics; e.g., last call isn't 'idle' until A resolves
  const lastCall = emitSpy.mock.calls[emitSpy.mock.calls.length - 1];
  expect(lastCall[0]).toBe('resource:fetch');
  expect(lastCall[1]).toMatchObject({ resource, status: 'fetching' });

  // Now resolve A; expect idle
  resolveA!('ok');
  await pA;
  const finalCall = emitSpy.mock.calls[emitSpy.mock.calls.length - 1];
  expect(finalCall).toEqual(['resource:fetch', { resource, status: 'idle' }]);
});

🏁 Script executed:

#!/bin/bash
# Locate the implementation of runAsyncTask to verify concurrency handling
rg -n "export async function runAsyncTask" -C8 packages/clerk-js/src/utils

Length of output: 1346


Enhance runAsyncTask to handle overlapping tasks without premature “idle” emits

The current implementation in packages/clerk-js/src/utils/runAsyncTask.ts always emits resource:fetchidle as soon as any task resolves, even if other tasks for the same resource are still in flight. This will cause flickering of the fetch status when two tasks overlap.

To address this, you should:

• Introduce an in-flight counter (e.g. a WeakMap<BaseResource, number>) inside runAsyncTask:
– Increment the counter before calling await task().
– Decrement in a finally block.
– Only emit { status: 'idle' } once the counter reaches zero.

• Add a concurrency test in packages/clerk-js/src/utils/__tests__/runAsyncTask.spec.ts to verify that overlapping tasks don’t flip back to idle until all tasks complete. For example:

it('does not emit idle until all overlapping tasks complete', async () => {
  const emitSpy = vi.spyOn(eventBus, 'emit');
  let resolveA!: (value: unknown) => void;
  const taskA = vi.fn(() => new Promise(res => (resolveA = res)));
  const taskB = vi.fn().mockResolvedValue('ok');

  const pA = runAsyncTask(resource, taskA);
  const pB = runAsyncTask(resource, taskB);
  await pB;

  // Should still be 'fetching' because A is not done
  const last = emitSpy.mock.calls.at(-1)!;
  expect(last).toEqual(['resource:fetch', { resource, status: 'fetching' }]);

  resolveA('ok');
  await pA;

  // Now finally emit 'idle'
  const final = emitSpy.mock.calls.at(-1)!;
  expect(final).toEqual(['resource:fetch', { resource, status: 'idle' }]);
});

Copy link
Member

Choose a reason for hiding this comment

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

@dstaley I'm not sure if this will happen in practice, but it's an interesting case to think about. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Member Author

Choose a reason for hiding this comment

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

technically yes, but I don't think there's a valid instance in which it could happen. You would need to trigger two async methods on the signIn instance at one time, and I'm not sure there's a legitimate case where you'd want that to work as intended. Could be a nice refactor in the future though, but probably YAGNI for now.

30 changes: 30 additions & 0 deletions packages/clerk-js/src/utils/runAsyncTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { eventBus } from '../core/events';
import type { BaseResource } from '../core/resources/internal';

/**
* Wrap an async task with handling for emitting error and fetch events, which reduces boilerplate. Used in our Custom
* Flow APIs.
*/
export async function runAsyncTask<T>(
Copy link
Member

Choose a reason for hiding this comment

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

🙃 Should this be runAsyncResourceTask or something instead? The name implies it being relatively generic, but that's not the case

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, lemme think on a new name. I was hoping I could co-locate this in a spot that made it obvious that it was for the custom flows implementation, but there's not really a good spot that makes that clear.

resource: BaseResource,
task: () => Promise<T>,
): Promise<{ result?: T; error: unknown }> {
eventBus.emit('resource:error', { resource, error: null });
eventBus.emit('resource:fetch', {
resource,
status: 'fetching',
});

try {
const result = await task();
return { result, error: null };
} catch (err) {
eventBus.emit('resource:error', { resource, error: err });
return { error: err };
} finally {
eventBus.emit('resource:fetch', {
resource,
status: 'idle',
});
}
}
Copy link
Contributor

@coderabbitai coderabbitai bot Aug 14, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use event constants and guard against overlapping task races with in-flight counting

  • Replace string event names with events.* for consistency and type-safety.
  • Track an in-flight counter per resource so idle isn’t emitted until all concurrent tasks complete. This prevents flickering fetchStatus in UIs when overlapping tasks occur.

Apply:

-import { eventBus } from '../core/events';
+import { events, eventBus } from '../core/events';
 import type { BaseResource } from '../core/resources/internal';
 
 /**
  * Wrap an async task with handling for emitting error and fetch events, which reduces boilerplate. Used in our Custom
  * Flow APIs.
  */
 export async function runAsyncTask<T>(
   resource: BaseResource,
   task: () => Promise<T>,
 ): Promise<{ result?: T; error: unknown }> {
-  eventBus.emit('resource:error', { resource, error: null });
-  eventBus.emit('resource:fetch', {
-    resource,
-    status: 'fetching',
-  });
+  // Keep a per-resource in-flight counter to avoid premature 'idle' on overlapping tasks.
+  // WeakMap ensures resources can be GC'd when no longer referenced.
+  const inflight: WeakMap<BaseResource, number> =
+    // Reuse a shared map on the eventBus instance to avoid module duplication across bundles/tests.
+    // @ts-expect-error: attach internal state
+    (eventBus.__inflight ??= new WeakMap<BaseResource, number>());
+
+  const inc = (inflight.get(resource) ?? 0) + 1;
+  inflight.set(resource, inc);
+
+  eventBus.emit(events.ResourceError, { resource, error: null });
+  if (inc === 1) {
+    eventBus.emit(events.ResourceFetch, {
+      resource,
+      status: 'fetching',
+    });
+  }
 
   try {
     const result = await task();
     return { result, error: null };
   } catch (err) {
-    eventBus.emit('resource:error', { resource, error: err });
+    eventBus.emit(events.ResourceError, { resource, error: err });
     return { error: err };
   } finally {
-    eventBus.emit('resource:fetch', {
-      resource,
-      status: 'idle',
-    });
+    const dec = (inflight.get(resource) ?? 1) - 1;
+    if (dec <= 0) {
+      inflight.delete(resource);
+      eventBus.emit(events.ResourceFetch, {
+        resource,
+        status: 'idle',
+      });
+    } else {
+      inflight.set(resource, dec);
+    }
   }
 }

Note: If you’d rather not attach internal state to eventBus, hoist the WeakMap to module scope.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { eventBus } from '../core/events';
import type { BaseResource } from '../core/resources/internal';
/**
* Wrap an async task with handling for emitting error and fetch events, which reduces boilerplate. Used in our Custom
* Flow APIs.
*/
export async function runAsyncTask<T>(
resource: BaseResource,
task: () => Promise<T>,
): Promise<{ result?: T; error: unknown }> {
eventBus.emit('resource:error', { resource, error: null });
eventBus.emit('resource:fetch', {
resource,
status: 'fetching',
});
try {
const result = await task();
return { result, error: null };
} catch (err) {
eventBus.emit('resource:error', { resource, error: err });
return { error: err };
} finally {
eventBus.emit('resource:fetch', {
resource,
status: 'idle',
});
}
}
import { events, eventBus } from '../core/events';
import type { BaseResource } from '../core/resources/internal';
/**
* Wrap an async task with handling for emitting error and fetch events, which reduces boilerplate. Used in our Custom
* Flow APIs.
*/
export async function runAsyncTask<T>(
resource: BaseResource,
task: () => Promise<T>,
): Promise<{ result?: T; error: unknown }> {
// Keep a per-resource in-flight counter to avoid premature 'idle' on overlapping tasks.
// WeakMap ensures resources can be GC'd when no longer referenced.
const inflight: WeakMap<BaseResource, number> =
// Reuse a shared map on the eventBus instance to avoid module duplication across bundles/tests.
// @ts-expect-error: attach internal state
(eventBus.__inflight ??= new WeakMap<BaseResource, number>());
const inc = (inflight.get(resource) ?? 0) + 1;
inflight.set(resource, inc);
eventBus.emit(events.ResourceError, { resource, error: null });
if (inc === 1) {
eventBus.emit(events.ResourceFetch, {
resource,
status: 'fetching',
});
}
try {
const result = await task();
return { result, error: null };
} catch (err) {
eventBus.emit(events.ResourceError, { resource, error: err });
return { error: err };
} finally {
const dec = (inflight.get(resource) ?? 1) - 1;
if (dec <= 0) {
inflight.delete(resource);
eventBus.emit(events.ResourceFetch, {
resource,
status: 'idle',
});
} else {
inflight.set(resource, dec);
}
}
}

Copy link
Member

Choose a reason for hiding this comment

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

@dstaley this is an interesting suggestion...do you think this could happen in practice?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Member Author

Choose a reason for hiding this comment

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

same thoughts as #6549 (comment)

1 change: 0 additions & 1 deletion packages/types/src/signIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ export interface SignInResource extends ClerkResource {
}

export interface SignInFutureResource {
fetchStatus: 'idle' | 'fetching';
availableStrategies: SignInFirstFactor[];
status: SignInStatus | null;
create: (params: {
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface State {
signInSignal: {
(): {
errors: Errors;
fetchStatus: 'idle' | 'fetching';
signIn: SignInFutureResource | null;
};
};
Expand Down
Loading