@@ -3,9 +3,51 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33import { eventBus } from '../events' ;
44import { SignIn } from '../resources/SignIn' ;
55import { SignUp } from '../resources/SignUp' ;
6- import { signInResourceSignal , signUpResourceSignal } from '../signals' ;
6+ import { signInFetchSignal , signInResourceSignal , signUpResourceSignal } from '../signals' ;
77import { State } from '../state' ;
88
9+ describe ( 'Signal batching' , ( ) => {
10+ let state : State ;
11+
12+ beforeEach ( ( ) => {
13+ state = new State ( ) ;
14+ } ) ;
15+
16+ it ( 'should produce at most 3 renders with clean fetchStatus transitions during an API call' , async ( ) => {
17+ const signIn = new SignIn ( null ) ;
18+ const snapshots : Array < { fetchStatus : string ; hasSignIn : boolean } > = [ ] ;
19+
20+ state . __internal_effect ( ( ) => {
21+ const s = state . signInSignal ( ) ;
22+ snapshots . push ( { fetchStatus : s . fetchStatus , hasSignIn : s . signIn !== null } ) ;
23+ } ) ;
24+
25+ await signIn . __internal_future . password ( { password : 'test123' , identifier : 'test@example.com' } ) . catch ( ( ) => {
26+ // Expected to fail since there's no real API
27+ } ) ;
28+
29+ expect ( snapshots . length ) . toBeLessThanOrEqual ( 3 ) ;
30+
31+ // fetchStatus follows a clean idle → fetching → idle progression
32+ const transitions = snapshots . map ( s => s . fetchStatus ) . filter ( ( s , i , arr ) => i === 0 || s !== arr [ i - 1 ] ) ;
33+ expect ( transitions ) . toEqual ( [ 'idle' , 'fetching' , 'idle' ] ) ;
34+ } ) ;
35+
36+ it ( 'should reflect new resource data immediately when no operation is in flight' , ( ) => {
37+ let latestSignInId : string | undefined ;
38+
39+ state . __internal_effect ( ( ) => {
40+ latestSignInId = state . signInSignal ( ) . signIn ?. id ;
41+ } ) ;
42+
43+ expect ( latestSignInId ) . toBeUndefined ( ) ;
44+
45+ new SignIn ( { id : 'signin_123' , status : 'needs_identifier' } as any ) ;
46+
47+ expect ( latestSignInId ) . toBe ( 'signin_123' ) ;
48+ } ) ;
49+ } ) ;
50+
951describe ( 'State' , ( ) => {
1052 let _state : State ;
1153
@@ -52,7 +94,7 @@ describe('State', () => {
5294 expect ( existingSignUp . __internal_future . canBeDiscarded ) . toBe ( false ) ;
5395
5496 // Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
55- const _nullSignUp = new SignUp ( null ) ;
97+ new SignUp ( null ) ;
5698
5799 // Assert: Signal should NOT be updated - should still have the existing SignUp
58100 expect ( signUpResourceSignal ( ) . resource ) . toBe ( existingSignUp ) ;
@@ -96,11 +138,6 @@ describe('State', () => {
96138 resetSignUp : vi . fn ( ) . mockImplementation ( function ( this : typeof mockClient ) {
97139 newSignUpFromReset = new SignUp ( null ) ;
98140 this . signUp = newSignUpFromReset ;
99- // reset() emits resource:error to clear errors, but the signal update
100- // happens via resource:update when the new SignUp is created
101- eventBus . emit ( 'resource:error' , { resource : newSignUpFromReset , error : null } ) ;
102- // Emit resource:update to update the signal (simulating what happens in real flow)
103- eventBus . emit ( 'resource:update' , { resource : newSignUpFromReset } ) ;
104141 } ) ,
105142 } ;
106143 SignUp . clerk = { client : mockClient } as any ;
@@ -127,10 +164,10 @@ describe('State', () => {
127164
128165 it ( 'should allow resource update when new resource has an id (not a null update)' , ( ) => {
129166 // Arrange: Set up a SignUp with id
130- const _existingSignUp = new SignUp ( { id : 'signup_123' , status : 'missing_requirements' } as any ) ;
167+ new SignUp ( { id : 'signup_123' , status : 'missing_requirements' } as any ) ;
131168 expect ( signUpResourceSignal ( ) . resource ?. id ) . toBe ( 'signup_123' ) ;
132169
133- // Act: Emit a resource update with a different SignUp that also has an id
170+ // Act: Create a different SignUp that also has an id
134171 const newSignUp = new SignUp ( { id : 'signup_456' , status : 'complete' } as any ) ;
135172
136173 // Assert: Signal should be updated with the new SignUp
@@ -159,7 +196,7 @@ describe('State', () => {
159196 expect ( existingSignIn . __internal_future . canBeDiscarded ) . toBe ( false ) ;
160197
161198 // Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
162- const _nullSignIn = new SignIn ( null ) ;
199+ new SignIn ( null ) ;
163200
164201 // Assert: Signal should NOT be updated - should still have the existing SignIn
165202 expect ( signInResourceSignal ( ) . resource ) . toBe ( existingSignIn ) ;
@@ -201,16 +238,13 @@ describe('State', () => {
201238 resetSignIn : vi . fn ( ) . mockImplementation ( function ( this : typeof mockClient ) {
202239 newSignInFromReset = new SignIn ( null ) ;
203240 this . signIn = newSignInFromReset ;
204- eventBus . emit ( 'resource:error' , { resource : newSignInFromReset , error : null } ) ;
205- eventBus . emit ( 'resource:update' , { resource : newSignInFromReset } ) ;
206241 } ) ,
207242 } ;
208243 SignIn . clerk = { client : mockClient } as any ;
209244
210245 // Create a SignIn with id
211246 const existingSignIn = new SignIn ( { id : 'signin_123' , status : 'needs_identifier' } as any ) ;
212247 expect ( signInResourceSignal ( ) . resource ?. id ) . toBe ( 'signin_123' ) ;
213- expect ( existingSignIn . __internal_future . canBeDiscarded ) . toBe ( false ) ;
214248
215249 // Act: Call reset()
216250 await existingSignIn . __internal_future . reset ( ) ;
@@ -224,10 +258,10 @@ describe('State', () => {
224258
225259 it ( 'should allow resource update when new resource has an id (not a null update)' , ( ) => {
226260 // Arrange: Set up a SignIn with id
227- const _existingSignIn = new SignIn ( { id : 'signin_123' , status : 'needs_identifier' } as any ) ;
261+ new SignIn ( { id : 'signin_123' , status : 'needs_identifier' } as any ) ;
228262 expect ( signInResourceSignal ( ) . resource ?. id ) . toBe ( 'signin_123' ) ;
229263
230- // Act: Emit a resource update with a different SignIn that also has an id
264+ // Act: Create a different SignIn that also has an id
231265 const newSignIn = new SignIn ( { id : 'signin_456' , status : 'complete' } as any ) ;
232266
233267 // Assert: Signal should be updated with the new SignIn
@@ -240,19 +274,19 @@ describe('State', () => {
240274 describe ( 'Edge cases' , ( ) => {
241275 it ( 'should handle rapid successive updates correctly' , ( ) => {
242276 // First update with valid SignUp
243- const _signUp1 = new SignUp ( { id : 'signup_1' , status : 'missing_requirements' } as any ) ;
277+ new SignUp ( { id : 'signup_1' , status : 'missing_requirements' } as any ) ;
244278 expect ( signUpResourceSignal ( ) . resource ?. id ) . toBe ( 'signup_1' ) ;
245279
246280 // Second update with another valid SignUp
247- const _signUp2 = new SignUp ( { id : 'signup_2' , status : 'missing_requirements' } as any ) ;
281+ new SignUp ( { id : 'signup_2' , status : 'missing_requirements' } as any ) ;
248282 expect ( signUpResourceSignal ( ) . resource ?. id ) . toBe ( 'signup_2' ) ;
249283
250284 // Null update should be ignored
251- const _nullSignUp = new SignUp ( null ) ;
285+ new SignUp ( null ) ;
252286 expect ( signUpResourceSignal ( ) . resource ?. id ) . toBe ( 'signup_2' ) ;
253287
254288 // Another valid update should work
255- const _signUp3 = new SignUp ( { id : 'signup_3' , status : 'complete' } as any ) ;
289+ new SignUp ( { id : 'signup_3' , status : 'complete' } as any ) ;
256290 expect ( signUpResourceSignal ( ) . resource ?. id ) . toBe ( 'signup_3' ) ;
257291 } ) ;
258292
@@ -262,10 +296,47 @@ describe('State', () => {
262296 expect ( signUpResourceSignal ( ) . resource ?. id ) . toBe ( 'signup_123' ) ;
263297
264298 // Manually emit update with the same instance (simulating fromJSON on same instance)
265- eventBus . emit ( 'resource:update ' , { resource : signUp } ) ;
299+ eventBus . emit ( 'resource:state-change ' , { resource : signUp } ) ;
266300
267301 // Signal should still have the same instance
268302 expect ( signUpResourceSignal ( ) . resource ) . toBe ( signUp ) ;
269303 } ) ;
270304 } ) ;
305+
306+ describe ( 'Client.destroy()' , ( ) => {
307+ it ( 'should update signals when resources are replaced with null instances' , async ( ) => {
308+ const mockSetActive = vi . fn ( ) . mockResolvedValue ( { } ) ;
309+ SignIn . clerk = { setActive : mockSetActive , client : { sessions : [ { id : 'session_123' } ] } } as any ;
310+
311+ const existingSignIn = new SignIn ( {
312+ id : 'signin_123' ,
313+ status : 'complete' ,
314+ created_session_id : 'session_123' ,
315+ } as any ) ;
316+ expect ( signInResourceSignal ( ) . resource ) . toBe ( existingSignIn ) ;
317+
318+ await existingSignIn . __internal_future . finalize ( ) ;
319+ expect ( existingSignIn . __internal_future . canBeDiscarded ) . toBe ( true ) ;
320+
321+ // Simulates what Client.destroy() does — creating a null resource replaces the existing one
322+ const nullSignIn = new SignIn ( null ) ;
323+
324+ expect ( signInResourceSignal ( ) . resource ) . toBe ( nullSignIn ) ;
325+ expect ( signInResourceSignal ( ) . resource ?. id ) . toBeUndefined ( ) ;
326+ } ) ;
327+ } ) ;
328+
329+ describe ( 'fetchStatus clearing on reset' , ( ) => {
330+ it ( 'should clear fetchStatus to idle when resource is reset during an in-flight fetch' , ( ) => {
331+ const signIn = new SignIn ( { id : 'signin_123' , status : 'needs_identifier' } as any ) ;
332+ eventBus . emit ( 'resource:state-change' , { resource : signIn , error : null , fetchStatus : 'fetching' } ) ;
333+ expect ( signInFetchSignal ( ) . status ) . toBe ( 'fetching' ) ;
334+
335+ // Reset replaces the resource and clears fetchStatus in one event
336+ const nullSignIn = new SignIn ( null ) ;
337+ eventBus . emit ( 'resource:state-change' , { resource : nullSignIn , error : null , fetchStatus : 'idle' } ) ;
338+
339+ expect ( signInFetchSignal ( ) . status ) . toBe ( 'idle' ) ;
340+ } ) ;
341+ } ) ;
271342} ) ;
0 commit comments