@@ -12,7 +12,15 @@ import '@angular/compiler';
1212/* eslint-enable import/no-unassigned-import */
1313
1414import { APP_BASE_HREF } from '@angular/common' ;
15- import { Component , PlatformRef , REQUEST , RESPONSE_INIT , inject } from '@angular/core' ;
15+ import {
16+ Component ,
17+ DestroyRef ,
18+ PendingTasks ,
19+ PlatformRef ,
20+ REQUEST ,
21+ RESPONSE_INIT ,
22+ inject ,
23+ } from '@angular/core' ;
1624import { ActivatedRoute , CanActivateFn , Router } from '@angular/router' ;
1725import { setTimeout } from 'node:timers/promises' ;
1826import { AngularServerApp } from '../src/app' ;
@@ -21,16 +29,21 @@ import { setAngularAppTestingManifest } from './testing-utils';
2129
2230describe ( 'AngularServerApp' , ( ) => {
2331 let app : AngularServerApp ;
32+ let slowRouteHook : ( ( ) => void ) | undefined ;
2433
2534 @Component ( {
2635 selector : 'app-home' ,
2736 template : `Home works` ,
2837 } )
2938 class HomeComponent {
3039 constructor ( ) {
31- if ( inject ( ActivatedRoute ) . snapshot . data [ 'destroyApp' ] ) {
40+ const data = inject ( ActivatedRoute ) . snapshot . data ;
41+ if ( data [ 'destroyApp' ] ) {
3242 inject ( PlatformRef ) . destroy ( ) ;
3343 }
44+ if ( data [ 'slow' ] ) {
45+ slowRouteHook ?.( ) ;
46+ }
3447 }
3548 }
3649
@@ -78,6 +91,7 @@ describe('AngularServerApp', () => {
7891 { path : 'page-with-headers' , component : HomeComponent } ,
7992 { path : 'page-with-status' , component : HomeComponent } ,
8093 { path : 'page-destroy-app' , component : HomeComponent , data : { destroyApp : true } } ,
94+ { path : 'page-slow' , component : HomeComponent , data : { slow : true } } ,
8195 { path : 'redirect' , redirectTo : 'home' } ,
8296 { path : 'redirect-via-navigate' , component : RedirectComponent } ,
8397 {
@@ -313,14 +327,106 @@ describe('AngularServerApp', () => {
313327 controller . abort ( ) ;
314328 } ) ;
315329
330+ await expectAsync ( app . handle ( request ) ) . toBeRejectedWithError (
331+ DOMException ,
332+ / R e q u e s t f o r : h t t p : \/ \/ l o c a l h o s t \/ h o m e w a s a b o r t e d / ,
333+ ) ;
334+ } ) ;
335+
336+ it ( 'should destroy the platform and stop rendering when an in-flight request is aborted' , async ( ) => {
337+ let resolveTaskReady ! : ( ) => void ;
338+ const taskReady = new Promise < void > ( ( resolve ) => {
339+ resolveTaskReady = resolve ;
340+ } ) ;
341+ let appDestroyed = false ;
342+ let destroySpy : jasmine . Spy | undefined ;
343+
344+ slowRouteHook = ( ) => {
345+ destroySpy = spyOn ( inject ( PlatformRef ) , 'destroy' ) . and . callThrough ( ) ;
346+ const pendingTasks = inject ( PendingTasks ) ;
347+ const removeTask = pendingTasks . add ( ) ;
348+ inject ( DestroyRef ) . onDestroy ( ( ) => {
349+ appDestroyed = true ;
350+ removeTask ( ) ;
351+ } ) ;
352+ resolveTaskReady ( ) ;
353+ } ;
354+
316355 try {
317- await app . handle ( request ) ;
318- throw new Error ( 'Should not be called.' ) ;
319- } catch ( e ) {
320- expect ( e ) . toBeInstanceOf ( DOMException ) ;
356+ const controller = new AbortController ( ) ;
357+ const request = new Request ( 'http://localhost/page-slow' , { signal : controller . signal } ) ;
358+
359+ const handlePromise = app . handle ( request ) ;
360+
361+ // Wait until the component is instantiated and holding a pending task
362+ await taskReady ;
363+ expect ( appDestroyed ) . toBeFalse ( ) ;
364+
365+ // Abort the request while `applicationRef.whenStable()` is still pending
366+ controller . abort ( ) ;
367+
368+ await expectAsync ( handlePromise ) . toBeRejectedWithError (
369+ DOMException ,
370+ / R e q u e s t f o r : h t t p : \/ \/ l o c a l h o s t \/ p a g e - s l o w w a s a b o r t e d / ,
371+ ) ;
372+
373+ // Wait for the macrotask queue to clear since platform destruction is scheduled via setTimeout(0)
374+ await setTimeout ( 0 ) ;
375+ expect ( destroySpy ) . toHaveBeenCalledTimes ( 1 ) ;
376+ expect ( appDestroyed ) . toBeTrue ( ) ;
377+ } finally {
378+ slowRouteHook = undefined ;
321379 }
322380 } ) ;
323381
382+ it ( 'should not bootstrap the platform if the request signal is already aborted' , async ( ) => {
383+ // Warm up the router so route extraction does not create a PlatformRef during this test
384+ const warmupResponse = await app . handle ( new Request ( 'http://localhost/home' ) ) ;
385+ await warmupResponse ?. text ( ) ;
386+ await setTimeout ( 0 ) ;
387+
388+ const destroySpy = spyOn ( PlatformRef . prototype , 'destroy' ) . and . callThrough ( ) ;
389+ const controller = new AbortController ( ) ;
390+ controller . abort ( ) ;
391+
392+ const request = new Request ( 'http://localhost/home' , { signal : controller . signal } ) ;
393+ await expectAsync ( app . handle ( request ) ) . toBeRejectedWithError ( DOMException ) ;
394+
395+ await setTimeout ( 0 ) ;
396+ expect ( destroySpy ) . not . toHaveBeenCalled ( ) ;
397+ } ) ;
398+
399+ it ( 'should clean up the abort event listener on request.signal after rendering completes' , async ( ) => {
400+ const controller = new AbortController ( ) ;
401+ const request = new Request ( 'http://localhost/home' , { signal : controller . signal } ) ;
402+ const removeEventListenerSpy = spyOn (
403+ request . signal ,
404+ 'removeEventListener' ,
405+ ) . and . callThrough ( ) ;
406+
407+ const response = await app . handle ( request ) ;
408+ expect ( await response ?. text ( ) ) . toContain ( 'Home works' ) ;
409+ await setTimeout ( 0 ) ;
410+
411+ expect ( removeEventListenerSpy ) . toHaveBeenCalledWith ( 'abort' , jasmine . any ( Function ) ) ;
412+ } ) ;
413+
414+ it ( 'should reject reading response body if request is aborted after handle() resolves but before rendering completes' , async ( ) => {
415+ const controller = new AbortController ( ) ;
416+ const response = await app . handle (
417+ new Request ( 'http://localhost/home' , { signal : controller . signal } ) ,
418+ ) ;
419+ expect ( response ) . not . toBeNull ( ) ;
420+
421+ controller . abort ( ) ;
422+
423+ await expectAsync ( response ?. text ( ) ) . toBeRejectedWithError (
424+ DOMException ,
425+ / R e q u e s t f o r : h t t p : \/ \/ l o c a l h o s t \/ h o m e w a s a b o r t e d / ,
426+ ) ;
427+ await setTimeout ( 0 ) ;
428+ } ) ;
429+
324430 it ( 'should return configured headers for pages with specific header settings' , async ( ) => {
325431 const response = await app . handle ( new Request ( 'http://localhost/page-with-headers' ) ) ;
326432 const headers = response ?. headers . entries ( ) ?? [ ] ;
0 commit comments