@@ -181,6 +181,37 @@ describe("useProbes", () => {
181181 expect ( arg . baseUrl ) . toBe ( "http://ops.test" ) ;
182182 } ) ;
183183
184+ it ( "clears stale error on baseUrl change (R3-C.3)" , async ( ) => {
185+ // baseUrl A errors → error set. Switch to baseUrl B (success) → error
186+ // must be cleared before B's fetch resolves so consumers don't render
187+ // a stale error against a fresh dep tuple.
188+ fetchProbesMock . mockRejectedValueOnce ( new Error ( "a failed" ) ) ;
189+ let resolveB : ( ( v : ProbesResponse ) => void ) | null = null ;
190+ fetchProbesMock . mockImplementationOnce (
191+ ( ) =>
192+ new Promise < ProbesResponse > ( ( r ) => {
193+ resolveB = r ;
194+ } ) ,
195+ ) ;
196+
197+ const { result, rerender } = renderHook (
198+ ( { baseUrl } : { baseUrl : string } ) => useProbes ( { baseUrl } ) ,
199+ { initialProps : { baseUrl : "http://a.test" } } ,
200+ ) ;
201+ await waitFor ( ( ) => expect ( result . current . error ?. message ) . toBe ( "a failed" ) ) ;
202+
203+ // Swap baseUrl — error must clear before B resolves.
204+ rerender ( { baseUrl : "http://b.test" } ) ;
205+ await waitFor ( ( ) => expect ( result . current . error ) . toBeNull ( ) ) ;
206+
207+ // Resolve B — still no error.
208+ await act ( async ( ) => {
209+ resolveB ! ( emptyProbes ( ) ) ;
210+ await Promise . resolve ( ) ;
211+ } ) ;
212+ expect ( result . current . error ) . toBeNull ( ) ;
213+ } ) ;
214+
184215 it ( "does not setData with stale data when deps change rapidly (CR-B1.3)" , async ( ) => {
185216 // Drive a sequence where the first effect's fetch resolves AFTER the
186217 // dep change has triggered a new effect. With the old aliveRef pattern,
@@ -339,6 +370,42 @@ describe("useProbeDetail", () => {
339370 await waitFor ( ( ) => expect ( result . current . data ?. probe . id ) . toBe ( "deep" ) ) ;
340371 } ) ;
341372
373+ it ( "clears stale error on id change (R3-C.2)" , async ( ) => {
374+ // First id errors → error populated. Switch id → error must clear before
375+ // the new fetch resolves so the panel never renders a stale error
376+ // beneath a different probe header.
377+ fetchProbeDetailMock . mockRejectedValueOnce ( new Error ( "first failed" ) ) ;
378+ let resolveSecond :
379+ | ( ( v : { probe : ProbeScheduleEntry ; runs : ProbeRun [ ] } ) => void )
380+ | null = null ;
381+ fetchProbeDetailMock . mockImplementationOnce (
382+ ( ) =>
383+ new Promise < { probe : ProbeScheduleEntry ; runs : ProbeRun [ ] } > ( ( r ) => {
384+ resolveSecond = r ;
385+ } ) ,
386+ ) ;
387+
388+ const { result, rerender } = renderHook (
389+ ( { id } : { id : string | null } ) => useProbeDetail ( id ) ,
390+ { initialProps : { id : "smoke" as string | null } } ,
391+ ) ;
392+ await waitFor ( ( ) =>
393+ expect ( result . current . error ?. message ) . toBe ( "first failed" ) ,
394+ ) ;
395+
396+ // Switch id — error must clear immediately (before the second fetch
397+ // resolves).
398+ rerender ( { id : "deep" } ) ;
399+ await waitFor ( ( ) => expect ( result . current . error ) . toBeNull ( ) ) ;
400+
401+ // Resolve second fetch — still no error.
402+ await act ( async ( ) => {
403+ resolveSecond ! ( { probe : entry ( "deep" ) , runs : [ ] } ) ;
404+ await Promise . resolve ( ) ;
405+ } ) ;
406+ expect ( result . current . error ) . toBeNull ( ) ;
407+ } ) ;
408+
342409 it ( "does not setData with stale data when id changes rapidly (CR-B1.3)" , async ( ) => {
343410 let resolveA :
344411 | ( ( v : { probe : ProbeScheduleEntry ; runs : ProbeRun [ ] } ) => void )
@@ -424,7 +491,7 @@ describe("useTriggerProbe", () => {
424491 ) ;
425492 const { result } = renderHook ( ( ) => useTriggerProbe ( { token : "t" } ) ) ;
426493 expect ( result . current . pending ) . toBe ( false ) ;
427- let p : Promise < TriggerResponse > ;
494+ let p : Promise < TriggerResponse | null > ;
428495 act ( ( ) => {
429496 p = result . current . trigger ( "smoke" ) ;
430497 } ) ;
@@ -490,10 +557,10 @@ describe("useTriggerProbe", () => {
490557 const { result, unmount } = renderHook ( ( ) =>
491558 useTriggerProbe ( { token : "t" } ) ,
492559 ) ;
493- let triggerPromise : Promise < TriggerResponse > | null = null ;
560+ let triggerPromise : Promise < TriggerResponse | null > | null = null ;
494561 act ( ( ) => {
495562 triggerPromise = result . current . trigger ( "smoke" ) ;
496- triggerPromise . catch ( ( ) => { } ) ;
563+ triggerPromise ? .catch ( ( ) => { } ) ;
497564 } ) ;
498565 await waitFor ( ( ) => expect ( triggerProbeMock ) . toHaveBeenCalled ( ) ) ;
499566 expect ( capturedSignal ) . toBeDefined ( ) ;
@@ -550,7 +617,7 @@ describe("useTriggerProbe", () => {
550617 } ,
551618 ) ;
552619 const { result } = renderHook ( ( ) => useTriggerProbe ( { token : "t" } ) ) ;
553- let firstPromise : Promise < TriggerResponse > | null = null ;
620+ let firstPromise : Promise < TriggerResponse | null > | null = null ;
554621 act ( ( ) => {
555622 firstPromise = result . current . trigger ( "smoke" ) ;
556623 // Swallow potential rejection so unhandled-rejection guards don't
@@ -560,7 +627,7 @@ describe("useTriggerProbe", () => {
560627 await waitFor ( ( ) => expect ( triggerProbeMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
561628
562629 // Fire second call — this aborts the first.
563- let secondPromise : Promise < TriggerResponse > | null = null ;
630+ let secondPromise : Promise < TriggerResponse | null > | null = null ;
564631 act ( ( ) => {
565632 secondPromise = result . current . trigger ( "smoke" ) ;
566633 } ) ;
@@ -570,24 +637,89 @@ describe("useTriggerProbe", () => {
570637
571638 // The first promise must NOT reject with AbortError surfaced — the
572639 // hook should swallow it. We resolve via the supersession path.
573- let firstResult : unknown = "pending" ;
640+ let firstResult : TriggerResponse | null | undefined = undefined ;
574641 let firstError : unknown = null ;
575- firstPromise !
642+ await firstPromise !
576643 . then ( ( v ) => {
577644 firstResult = v ;
578645 } )
579646 . catch ( ( e ) => {
580647 firstError = e ;
581648 } ) ;
582- // Yield enough for any pending settlements.
583- await Promise . resolve ( ) ;
584- await Promise . resolve ( ) ;
585- // Per spec: first promise resolves silently (returns undefined) when
649+ // Per spec: first promise resolves silently (returns null) when
586650 // superseded; AbortError must NOT be thrown to the caller.
587651 expect ( ( firstError as { name ?: string } ) ?. name ) . not . toBe ( "AbortError" ) ;
588- void firstResult ;
652+ // R3-C.1: supersession resolves to null (typed), not undefined-cast.
653+ expect ( firstResult ) . toBeNull ( ) ;
589654
590655 // Error state must remain null.
591656 expect ( result . current . error ) . toBeNull ( ) ;
592657 } ) ;
658+
659+ it ( "supersession path resolves to null and is discriminable (R3-C.1)" , async ( ) => {
660+ // Caller-facing contract: a superseded trigger resolves to `null`, never
661+ // to a fake TriggerResponse. The type system reflects this so callers
662+ // can `if (r === null)` to detect supersession.
663+ triggerProbeMock . mockImplementationOnce (
664+ ( _id : string , opts : { signal ?: AbortSignal } ) =>
665+ new Promise < TriggerResponse > ( ( _resolve , reject ) => {
666+ opts . signal ?. addEventListener ( "abort" , ( ) => {
667+ reject ( new DOMException ( "aborted" , "AbortError" ) ) ;
668+ } ) ;
669+ } ) ,
670+ ) ;
671+ triggerProbeMock . mockImplementationOnce ( ( ) =>
672+ Promise . resolve ( triggerOk ( ) ) ,
673+ ) ;
674+ const { result } = renderHook ( ( ) => useTriggerProbe ( { token : "t" } ) ) ;
675+
676+ let firstPromise ! : Promise < TriggerResponse | null > ;
677+ act ( ( ) => {
678+ firstPromise = result . current . trigger ( "smoke" ) ;
679+ firstPromise . catch ( ( ) => { } ) ;
680+ } ) ;
681+ await waitFor ( ( ) => expect ( triggerProbeMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
682+
683+ // Supersede with a second call.
684+ let secondPromise ! : Promise < TriggerResponse | null > ;
685+ act ( ( ) => {
686+ secondPromise = result . current . trigger ( "smoke" ) ;
687+ } ) ;
688+ const secondValue = await act ( async ( ) => secondPromise ) ;
689+
690+ // Second call returns a real response.
691+ expect ( secondValue ) . not . toBeNull ( ) ;
692+ expect ( secondValue ?. runId ) . toBe ( "run-1" ) ;
693+
694+ // First call resolves to null (the discriminator).
695+ const firstValue = await firstPromise ;
696+ expect ( firstValue ) . toBeNull ( ) ;
697+ // Discriminate via strict null check.
698+ expect ( firstValue === null ) . toBe ( true ) ;
699+ } ) ;
700+
701+ it ( "clears error on next successful trigger (R3-C.4)" , async ( ) => {
702+ // First call fails → error set. Second call succeeds → error cleared.
703+ triggerProbeMock . mockRejectedValueOnce ( new Error ( "forbidden" ) ) ;
704+ triggerProbeMock . mockResolvedValueOnce ( triggerOk ( ) ) ;
705+
706+ const { result } = renderHook ( ( ) => useTriggerProbe ( { token : "t" } ) ) ;
707+
708+ await act ( async ( ) => {
709+ try {
710+ await result . current . trigger ( "smoke" ) ;
711+ } catch {
712+ // expected
713+ }
714+ } ) ;
715+ await waitFor ( ( ) =>
716+ expect ( result . current . error ?. message ) . toBe ( "forbidden" ) ,
717+ ) ;
718+
719+ await act ( async ( ) => {
720+ await result . current . trigger ( "smoke" ) ;
721+ } ) ;
722+ // Error must be cleared by the successful trigger.
723+ await waitFor ( ( ) => expect ( result . current . error ) . toBeNull ( ) ) ;
724+ } ) ;
593725} ) ;
0 commit comments