@@ -119,6 +119,27 @@ describe('Scheduler — flush', () => {
119119 errorSpy . mockRestore ( ) ;
120120 } ) ;
121121
122+ // the cycle cap spans both queues with a unified iteration counter — a reaction
123+ // that schedules an afterFlush that re-invalidates the reaction must also hit it
124+ it ( 'breaks a reaction↔afterFlush cycle and logs an error' , ( ) => {
125+ const errorSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
126+ const trigger = new Signal ( 0 ) ;
127+
128+ Reaction . create ( ( ) => {
129+ trigger . get ( ) ;
130+ Reaction . afterFlush ( ( ) => trigger . set ( trigger . peek ( ) + 1 ) ) ;
131+ } ) ;
132+
133+ trigger . set ( 1 ) ;
134+ Reaction . flush ( ) ;
135+
136+ expect ( errorSpy ) . toHaveBeenCalled ( ) ;
137+ expect ( errorSpy . mock . calls . some ( c => / c y c l e d e t e c t e d / i. test ( c [ 0 ] ) ) ) . toBe ( true ) ;
138+ expect ( Scheduler . pendingReactions . size ) . toBe ( 0 ) ;
139+ expect ( Scheduler . afterFlushCallbacks . length ) . toBe ( 0 ) ;
140+ errorSpy . mockRestore ( ) ;
141+ } ) ;
142+
122143 // exception in one reaction must not silently swallow others in the same batch
123144 // either it propagates or framework isolates each, this test pins which
124145 it ( 'continues processing remaining reactions when one throws' , ( ) => {
@@ -210,8 +231,7 @@ describe('Scheduler — flush', () => {
210231 expect ( settled ) . toHaveBeenCalledTimes ( 1 ) ;
211232 } ) ;
212233
213- // late-registered afterFlush queues for the next flush, otherwise self-registering callbacks would infinite-loop
214- it ( 'does not run afterFlush callbacks registered DURING afterFlush in the same pass' , ( ) => {
234+ it ( 'drains afterFlush callbacks registered during afterFlush in the same flush' , ( ) => {
215235 let runCount = 0 ;
216236 const recursive = ( ) => {
217237 runCount ++ ;
@@ -222,10 +242,45 @@ describe('Scheduler — flush', () => {
222242 Reaction . afterFlush ( recursive ) ;
223243
224244 Reaction . flush ( ) ;
225- expect ( runCount ) . toBe ( 1 ) ;
245+ expect ( runCount ) . toBe ( 5 ) ;
246+ } ) ;
247+
248+ it ( 'schedules a flush when afterFlush registers with no pending work' , async ( ) => {
249+ const cb = vi . fn ( ) ;
250+ Reaction . afterFlush ( cb ) ;
251+ await Promise . resolve ( ) ;
252+ expect ( cb ) . toHaveBeenCalledTimes ( 1 ) ;
253+ } ) ;
254+
255+ it ( 'keeps draining when an afterFlush callback throws' , ( ) => {
256+ const survivor = vi . fn ( ) ;
257+ Reaction . afterFlush ( ( ) => {
258+ throw new Error ( 'boom' ) ;
259+ } ) ;
260+ Reaction . afterFlush ( survivor ) ;
261+
262+ expect ( ( ) => Reaction . flush ( ) ) . toThrow ( 'boom' ) ;
263+ expect ( survivor ) . toHaveBeenCalledTimes ( 1 ) ;
264+ } ) ;
265+
266+ it ( 'drains reactions queued by an afterFlush callback before the next callback runs' , ( ) => {
267+ const source = new Signal ( 'initial' ) ;
268+ const derived = new Signal ( 'initial' ) ;
269+
270+ Reaction . create ( ( ) => {
271+ derived . set ( `reaction-saw-${ source . get ( ) } ` ) ;
272+ } ) ;
273+
274+ let observedInCb2 ;
275+ Reaction . afterFlush ( ( ) => {
276+ source . set ( 'updated-by-cb1' ) ;
277+ Reaction . afterFlush ( ( ) => {
278+ observedInCb2 = derived . peek ( ) ;
279+ } ) ;
280+ } ) ;
226281
227282 Reaction . flush ( ) ;
228- expect ( runCount ) . toBe ( 2 ) ;
283+ expect ( observedInCb2 ) . toBe ( 'reaction-saw-updated-by-cb1' ) ;
229284 } ) ;
230285} ) ;
231286
@@ -262,6 +317,32 @@ describe('Scheduler — current reaction context', () => {
262317 expect ( s . hasDependents ( ) ) . toBe ( false ) ;
263318 } ) ;
264319
320+ it ( 'advances firstRun even when the callback throws, so re-invalidation tracks fresh deps' , ( ) => {
321+ const trigger = new Signal ( 0 ) ;
322+ let throwOnce = true ;
323+ const callback = vi . fn ( ) ;
324+ let reaction ;
325+
326+ // Reaction.create throws because the first run does, so capture the instance via the callback arg
327+ expect ( ( ) => {
328+ Reaction . create ( ( r ) => {
329+ reaction = r ;
330+ trigger . get ( ) ;
331+ callback ( r . firstRun ) ;
332+ if ( throwOnce ) {
333+ throwOnce = false ;
334+ throw new Error ( 'first run throws' ) ;
335+ }
336+ } ) ;
337+ } ) . toThrow ( 'first run throws' ) ;
338+
339+ expect ( reaction . firstRun ) . toBe ( false ) ;
340+
341+ trigger . set ( 1 ) ;
342+ Reaction . flush ( ) ;
343+ expect ( callback ) . toHaveBeenLastCalledWith ( false ) ;
344+ } ) ;
345+
265346 it ( 'nonreactive nests correctly — restores outer reaction when inner returns' , ( ) => {
266347 const outer = new Signal ( 'outer' ) ;
267348 const inner = new Signal ( 'inner' ) ;
@@ -467,6 +548,33 @@ describe('Reaction.guard', () => {
467548 outer . stop ( ) ;
468549 expect ( counter . dependency . subscribers . size ) . toBe ( 0 ) ;
469550 } ) ;
551+
552+ it ( 'propagates value changes after the first f() throws' , ( ) => {
553+ const source = new Signal ( 'first' ) ;
554+ let throwOnce = true ;
555+ const downstream = vi . fn ( ) ;
556+
557+ expect ( ( ) => {
558+ Reaction . create ( ( ) => {
559+ const v = Reaction . guard ( ( ) => {
560+ const x = source . get ( ) ;
561+ if ( throwOnce ) {
562+ throwOnce = false ;
563+ throw new Error ( 'first run throws' ) ;
564+ }
565+ return x ;
566+ } ) ;
567+ downstream ( v ) ;
568+ } ) ;
569+ } ) . toThrow ( 'first run throws' ) ;
570+
571+ expect ( downstream ) . not . toHaveBeenCalled ( ) ;
572+
573+ // a signal change re-fires the inner guard, which now succeeds and propagates upward
574+ source . set ( 'second' ) ;
575+ Reaction . flush ( ) ;
576+ expect ( downstream ) . toHaveBeenCalledWith ( 'second' ) ;
577+ } ) ;
470578} ) ;
471579
472580/*******************************
0 commit comments