single-spa / single-spa-angular

Helpers for building single-spa applications which use Angular
Apache License 2.0
199 stars 78 forks source link

URL Redirection Infinite Loop #113

Closed AndrewGlazier closed 3 years ago

AndrewGlazier commented 4 years ago

Issue seems to relate to this comment; https://github.com/CanopyTax/single-spa-angular/issues/94#issuecomment-520550455 Creating a new ticket as I cannot reproduce using the browser back button.

This bug is reproducible in the vanilla example for coexisting angular micro-frontends found here: https://github.com/joeldenning/coexisting-angular-microfrontends

Reproduction steps; Using the navigation app, trigger a route change, and then quickly trigger another nav change.

Issue is reproducible on; IE11 (Very common) Firefox (Mediocre-Hard) Chrome (Rare)

Notes; In our application we have found that having more simultaneous angular apps running in parallel triggers the issue more commonly, however this bug is reproducible with only one app actively loaded by the single-spa (however with difficultly even in IE11). (Issue does not occur at all with the app running in angular standalone without the spa)

The issue appears to be also related to Router.forRoot in some way, upon removing this from an app, the issue becomes more difficult to reproduce even with IE11, a similar effect to whether the app wasn't running.

This seems to be related to the speed of the navigation. Navigating twice very quickly causes this effect.

If struggling to reproduce, we found that having a button which changes to swap between two pages and spam clicking it causes the bug to reproduce on IE11 every time.

The navigations do not need to be between separate apps, routing between two pages on the same app causes the issue.

joeldenning commented 4 years ago

I do not have time to work on this issue - outside contributions would be greatly appreciated.

rknash18 commented 4 years ago

@AndrewGlazier I have fixed this issue by adding the onSameUrlNavigation: 'ignore' option to router module in angular.

joeldenning commented 4 years ago

Oh wow thank you for sharing that @rknash18. It would be great if that workaround works for everyone else's issues they've been having, too. I've commented in #94 asking others to test if this works for them.

If it does, let's look into incorporating this into single-spa-angular so that all benefit from it.

ashuaggarwal94 commented 4 years ago

Hi @joeldenning , @rknash18 ,

The solution provided in the comment does not work for me I m stilling using the changeDectection ref as an alternative.

rknash18 commented 4 years ago

@ashuaggarwal94 I am not sure about the issue you are experiencing , I have fixed issue with router trying go back on forth when I clicked on a menu option.

gubertcalixto commented 4 years ago

I identified that this loop starts in reroute.js file image In my case performAppChanges reached 400 times until the page was available again

Edit 1: Also find out that it happens because of replaceState in navigation-event.js file, which calls reroute.js above methods. image As you can see, it happens in the following order:

  1. pushState (navigation-event.js)
  2. performAppChanges (reroute.js)
  3. replaceState (navigation-event.js)
  4. performAppChanges (reroute.js)
  5. pushState (navigation-event.js)
  6. LOOP BETWEEN performAppChanges and replaceState

Default behavior:

  1. pushState
  2. performAppChanges

The conflict occurs between following routes https://my-url/route-before-problem/ https://my-url/route-trying-to-access No other routes are involved in proccess (what I mean is that even though Angular makes redirects because of authorization it always happens between same two routes)

gubertcalixto commented 4 years ago

My current guess is this line. I think the second line urlReroute(createPopStateEvent(state)) is runned after the second router change, causing unexpected behavior since browser is already changing again (to new url) and single-spa is processing the old url. PS: JUST A GUESS, trying to debug it a little more.

joeldenning commented 4 years ago

Thanks for helping to debug this - why would single-spa's code have the old url? Angular router should call replaceState with the new url, not the old one.

gubertcalixto commented 4 years ago

What I meant was that single-spa reroute is happening during an router change, which causes the bug, but not sure about it at all. It feels like single spa is processing during another route change.

gubertcalixto commented 4 years ago

Test it out for about 2 hours. I'm creating an repo with the bug! Notice that loop starts after "finishUpAndReturn" method is called twice, @joeldenning, any idea?

gubertcalixto commented 4 years ago

Created an repo that reproduces the bug @joeldenning

joeldenning commented 4 years ago

I am not going to look at this bug anytime soon. I do not use Angular for any projects ever and this bug is a hard one when I tried to fix it in the past. The only path forward for this to be fixed is community contribution.

gubertcalixto commented 4 years ago

Notice something that might be the bug!!!

Base Code

this.router.navigateByUrl('threee').catch(res => {
    this.router.navigateByUrl('one');
});

The Result

image This image shows the arguments send to replaceState and to pushState.

What I Found Out

  1. As you can see, urls has /test prefix
    • This is because I added provide: APP_BASE_HREF, useValue: '/test', making all urls for that app to have /test prefix (/test will be in activtyFunction of SingleSPA)
  2. What I'm interested in is that first url is /test/two, which means that Angular doesn't have changed route to threee.
    • Actually, /test/two is the current route in Angular App (before redirecting to any route at all)
  3. After that Angular itself keep activating replaceState and pushState, but only when singleSpa overrides this methods from window.history

PS: Even though I do not alter APP_BASE_HREF the bug keeps happening and Angular keeps sending "/two" other than "/threee"

PS 2: I'm thinking that I could fix the problem checking if url is the same as current url. But I don't know if it can cause any other problems. Any other suggestions please 🙏?

gubertcalixto commented 4 years ago

Notice that the problem is in the following line If I delete the following line, bug is fixed.

@TheMcMurder (git blamed this code). I just need to know why is this code there. Because I not sure what might happen if I just delete it. And what you suggest, should I try to get why is pendingPromises making angular start a loop or just do something to fix this code (still don't know what)?

TheMcMurder commented 4 years ago

Full disclosure: I don't use angular for any projects.

I don't recall specifically why that line of code exists, but this is a really complicated interaction between two different libraries: angular-routing, and single-spa. They both operate with different ideas about how routing is to be accomplished and who is in control of routing.

I'll try to take a look at the repo you created, but if I'm understanding correctly it's only consistently reproducible in IE which will make debugging extremely hard (I use linux as my daily driver). I wouldn't suggest deleting lines of code in single-spa as a solution. I know it's a slow response and I thank you for your continued patience.

gubertcalixto commented 4 years ago

@TheMcMurder Actually is totally reproducible in Chrome / Firefox (using Windows at least). The bug happens 100% in my repo using the following settings: Chrome v 79.0.3945.117 (64 bits) Windows 10 (64 bits) v 18363.592 or in Firefox v 72.0.1 (64-bits) Microsoft Edge v 44.18362.449.0 (Microsoft EdgeHTML v 18.18363) Blisk v 12.0.92.83 (Official Build) (32-bit)

IE: I didn't tried.

PS: I removed that line from single-spa and builded to test and works just fine

OriolInvernonLlaneza commented 4 years ago

Hi, I can trigger the loop in my apps with Chrome and Ubuntu too.

kylethebaker commented 4 years ago

Also can confirm this happens in several of our angular micro-apps, running both the latest chrome and firefox nightly. It doesn't happen on every navigation event, but when it does happen it will happen consistently for a particular one.

This is something that I'm investigating as well, I'll post here if I figure anything out.

kisslove commented 4 years ago

the redirection infinite loop stills exist.any solutions?

kisslove commented 4 years ago

I try all solutions above,but failed. so i spend one day to solve the problem.luckily,it is fixed for me. update file:src/navigation/navigation-events.js details as my fork: https://github.com/kisslove/single-spa/blob/master/src/navigation/navigation-events.js

gubertcalixto commented 4 years ago

@kisslove Could you explain your commit, why adding "isNavGoOrBack" made it work? I think your commit is about go back in browser bug, but actually the route loop can occur in others situations (in my repo I made an example only using 1 redirect after another)

kisslove commented 4 years ago

@gubertcalixto I add ‘isNavGoOrBack‘ ,that just make a distinction between click back-forward in browser and normal route. because the popstate event will be triggered by doing a browser action such as a click on the back or forward button (or calling history.back() or history.forward() in JavaScript). Normal route will trigger history.pushState and history.replaceState,but go back in browser only trigger history.replaceState. the problem is history.pushState and history.replaceState both call urlReroute(createPopStateEvent(state)).obsolutely,it duplicates.

kisslove commented 4 years ago

@gubertcalixto I test your repo,it works after using my update.

pankajparkar commented 4 years ago

This issue only happens if user click on two different single spa link in a fraction of second. To fix this issue I disabled the links until any of the single page application is loading.

When any application loads via single-spa, it runs below events at start and end. start - single-spa:before-routing-event end - single-spa:routing-event In between there are other events also gets called.

@HostListener('window:single-spa:before-routing-event')
beforeRouting() {
    const navbar: HTMLHtmlElement = document.querySelector('navbar');
    const activeLink: HTMLHtmlElement = document.querySelector('.nav-link.active')
    navbar.style.pointerEvents = 'none';
    activeLink.style.classList.add('loading');
}

@HostListener('window:single-spa:routing-event')
afterRoutingEvent() {
    setTimeout(_ => {
      const navbar: HTMLHtmlElement = document.querySelector('navbar');
      const activeLink: HTMLHtmlElement = document.querySelector('.nav-link.active')
      navbar.style.pointerEvents = 'none';
      activeLink.style.classList.remove('loading');
    }, 500);
}
gubertcalixto commented 4 years ago

@kisslove maybe you could create an PR with your correction for single spa. The bugs that I know/heard about was:

  1. Reproduced in my repo (fast change of route or using route promises)
  2. The bug you tested (navigate back/forward using browser actions)
  3. Another one that happened because of multiple angular redirects in production because of an router guard (but not sure if this one is the same of the tested in my repo).

PS: If you don't do it I probably will do in the next month (on vacation right now 😎)

kylethebaker commented 4 years ago

@pankajparkar The cases where I see it is when the back button is pressed or when doing a single direct navigation from using the router (not triggered by user interaction). It's possible though that either of those are causing cascading navigation events (guards, redirects, etc), so the theory of "too many navigation's too quickly" is valid.

I'm using the fork by @kisslove and it appears to be working, I did notice one time where the redirects started for a couple of bounces and then resolved themselves quickly, but that was single time out of several. So far I haven't seen any other negative side-effects or regressions within my apps from it.

kisslove commented 4 years ago

@gubertcalixto , @kylethebaker .Notice that my pre updates could lead to some display problems when it routes between multi angular micro-fronts. Now,my solution is that adding a setTimeout fn to solve the loop.it also appears to be working. only update here.

window.history.replaceState = function (state) {
    const result = originalReplaceState.apply(this, arguments);
    // delete the row:
    //urlReroute(createPopStateEvent(state));
    //update as follow:
    setTimeout(() => {
        urlReroute(createPopStateEvent(state));
    }, 50);
    return result;
};
kylethebaker commented 4 years ago

@kisslove with the setTimeout it's worse for me, I get stuck in the loop but it recovers after a few seconds (so no longer infinite but still won't work for us). I've noticed with the original change that I still do a really quick back-and-forth redirect when it happens, it just stops early enough that it's not very noticeable.

I'm also digging around with the navigation trying to see if I can get to the bottom of it. It does seem like there might be some kind of timing issue at hand though.

pankajparkar commented 4 years ago

Our application bundles are quite heavy, they take seconds to load, and when these bundles are downloading and bootstrapping the application, if user try to switches between applications, redirection get into infinite loop.

The solution I applied is based on my assumption seems to be working for me. It could have been wrong in this approach. Please suggest if there is better way to fix this issue.

pankajparkar commented 4 years ago

@kylethebaker I will give a shot with @kisslove 's solution. Just curious to know, after you updating the single-spa code, how you're utilising this modified package. From forked git repository, or you have hosted this package somewhere else?

kylethebaker commented 4 years ago

@pankajparkar We have a private npm package registry that we use for internal packages, I forked it and published there.

There might be a simpler way to do it, but I updated the package name to @our-namespace/single-spa and also updated the typings.d.ts to reflect this new name, then updated the consumer app's package.json and imports to use the new package.

pankajparkar commented 4 years ago

@kylethebaker Thanks for sharing the way. I'm afraid doing that, because in future single-spa releases, I won't be receiving any updates, Every update has to done manually.

joeldenning commented 4 years ago

Perhaps releated - https://github.com/single-spa/single-spa/issues/438#issue-564495428

gubertcalixto commented 4 years ago

After a long time debugging, found out that, at least the bug that is in my repo, occurs due to this line. I still could not found the reason of the problem but may have some ideas / conclusions (I AM NOT 100% WHAT I'M SAYING):

  1. When Angular navigates to an route using a promise which gets in a catch, single-spa receives a replaceState using the same route (before the redirect that fails).
    • If was in /one and failed to /two, Angular send a replaceState to /one
  2. It seems that single-spa calls for the same promise that just happened in Angular (doing a loop between routes /one and /two).
  3. Commenting this line or doing an if in this line, only activating it if arguments[2] !== location.pathname being arguments[2] equal to the redirect path.

PS: The example in my repo is the following:

joeldenning commented 4 years ago

I debugged this a while ago and came to the same conclusion @gubertcalixto. My best guess is that Angular Router relies on all navigation events occurring via the router and within the same Zone. In single-spa, other applications sometimes cause navigation events which causes problems for Angular Router. Also, even within a single application the problem can occur because single-spa delays the popstate event from firing to all the listeners on the page until it knows whether to unmount the applications in charge of those listeners or not. This hasn't been a problem for any framework except for Angular, and my best guess is that it is related to the popstate event occurring outside of the Zone or outside of the Angular Router instance.

^ This is all the information I have. It sounds like you've made a ton of progress. If you get to a point where you have a suggested fix, that would be amazing. This issue has plagued single-spa-angular for a long time.

joeldenning commented 4 years ago

From https://github.com/single-spa/single-spa/issues/438:

Could you please provide me some context on why do we override window event listener functions and capturing popstate and hashchange events and processing them. Or can you redirect me to any documentation around single spa architecture. It would be of great help if you provide this information.

Sure! Single-spa overrides window.addEventListener and window.removeEventListener to prevent single-spa applications that are about to be unmounted from receiving a PopStateEvent or HashChangeEvent. Since single-spa applications are only aware of the routes described by their activity functions, they would show a Not Found 404 page if we let the PopStateEvent / HashChangeEvent through to them before we unmount them.

In other words, single-spa needs to be the "first" handler of the popstate and hashchange events. And its handling of those events is asynchronous, since it calls bootstrap(), mount(), and unmount() during its handling of those events. Since it needs to be (1) first, and (2) asynchronous, we withhold the routing events from all single-spa applications until it finishes. The patching of window.addEventListener is the only way that I'm aware of to accomplish that.

Here are some links in the single-spa source code that might shed further light on how it's happening:

It's worth noting that no other frameworks have issue with this approach - react, vue, and others all are fine with it. My guess for the last while is that ZoneJS / Angular Router are assuming a specific timing or zone for the popstate and hashchange events that does not always happen when single-spa withholds the event for a short time before firing the Angular Router popstate event handler.

muhlba91 commented 4 years ago

@joeldenning @gubertcalixto we are facing the same issue. Is there any progress or are there ideas on how to get it fixed? :)

joeldenning commented 4 years ago

Hi @muhlba91 - as I've said above, I would appreciate outside contribution on this issue.

Angular router and ZoneJS are extremely complex (I've read their source code!) and have been difficult for me to debug. I personally do not use Angular very often and single-spa-angular is often a source of pain and frustration for me, since I find Angular's approach (with schematics, hidden webpack config, and Angular CLI) to be quite brittle. I often do not prioritize spending time on single-spa-angular simply because I do not enjoy it.

I recently quit my job to do open source + consulting full time. I may get to this issue at some point, but have many other things I'll likely do before shifting attention to this. I would gladly prioritize working on this issue if sponsored (Twitter @joelbdenning). Otherwise, I'll look at this issue eventually on a good day when I'm mentally prepared to spend several hours reading minified angular router code 😄

muhlba91 commented 4 years ago

Yeah, I totally see your point here and I’m not deep into frontend development myself and prefer more lightweight frameworks like vue and debugging such code is not my favorite thing to spend my days with. 😄 (And kudos for reading through both libraries and your open answer is really appreciated! 👍)

Well, business needs and companies go for various reason for Angular which is why I ended up digging through that as well today while trying to debug the problem. 🙈

I’m willing to spend some time/days on trying to at least find a working „hack/workaround“ which could then, hopefully, also open possibilities for a fix. 😃 However, as you have said already, the way all of those libraries work together is quite complex and I was hoping for any direction or idea you have got where I could start to pin down the problem?

joeldenning commented 4 years ago

where I could start to pin down the problem?

See https://github.com/single-spa/single-spa-angular/issues/113#issuecomment-592981422 and https://github.com/single-spa/single-spa/releases/tag/v5.2.0

joeldenning commented 4 years ago

Also note that this is almost certainly related to https://github.com/single-spa/single-spa-angular/issues/94

joeldenning commented 4 years ago

I believe that this is fixed in https://github.com/single-spa/single-spa-angular/releases/tag/v3.3.1 and https://github.com/single-spa/single-spa-angular/releases/tag/v4.0.0. single-spa-angular@3 is for Angular 2-8, and single-spa-angular@4 is for Angular 9. See #94 for the explanation and links to PRs that fixed it.

If someone could confirm that this is fixed in 3.3.1 and 4.0.0, please let me know here so I can close this issue.

white-collar commented 4 years ago

If someone could confirm that this is fixed in 3.3.1 and 4.0.0, please let me know here so I can close this issue.

I can confirm - Issue is fixed

joeldenning commented 4 years ago

Great! Big thanks to @arturovt for finding and fixing this

OriolInvernonLlaneza commented 4 years ago

If someone could confirm that this is fixed in 3.3.1 and 4.0.0, please let me know here so I can close this issue.

I can confirm - Issue is fixed

Hi @white-collar . Did you confirm it in 3.3.1 or 4.0? I'm on the process of updating our Angular 8 micros to 3.3.1 and If I navigate too fast between two of them I still get the infinite loop.

matt-gold commented 4 years ago

I am seeing the infinite redirect bug after clicking the back button between two single-spa-angular 4.0 apps, using the latest single-spa 5.3.4. The bug does not appear if I use @kisslove 's fork of single-spa mentioned in this thread, which is what we've been using as a workaround for this bug for the past few months.

There are a lot of moving parts in my application, but I'll try to get a repo standing that concisely reproduces the issue.

More background on my app which is admittedly not the intended/recommended setup - rather than have single-spa manage the lifecycle of my angular apps, I have a root Angular application that loads other single-spa-angular apps at certain routes as parcels. I noticed that with single-spa-angular 4.0 there've been some changes to better isolate the zones when multiple angular apps are running together. namely the monkey patch of NgZone.isInAngularZone and injecting the zone into the SingleSpaPlatformLocation. I figured I should do the same thing in my root Angular app to try to fix this issue, unfortunately it doesn't seem to make a difference. Here is the bootstrap code from my root app for reference (much of which is lifted from single-spa-angular source):

  const appIdentifier = 'myAppRoot';
  NgZone.isInAngularZone = () => {
    // @ts-ignore
    return window.Zone.current._properties[appIdentifier] === true;
  };
  const module = platformBrowserDynamic(getSingleSpaExtraProviders())
    .bootstrapModule(AppModule);

  /// cast to any because SingleSpaPlatformLocation is not exported
  const rootPlatformLocation = module.injector.get(PlatformLocation) as any;
  const rootZone = module.injector.get(NgZone);

  // tslint:disable-next-line:no-string-literal
  rootZone['_inner']._properties[appIdentifier] = true;
  rootPlatformLocation.setNgZone(rootZone);
vaibhavarora14 commented 4 years ago

I also have a situation of infinite URL, but with different code. recreated that situation with https://github.com/varora1406/coexisting-angular-microfrontends/commit/7073a82e237d47fbb809beb3e12a861c2d91c4d3.

Any helpful comment from community on it?

joeldenning commented 4 years ago

To solve this bug, we need Angular router to ignore the single-spa popstateevent if it was the app that triggered the navigation by calling pushState().

@arturovt and I have discussed this but haven't found a solution for it. We have both shifted our attention to other work right now, but would gladly accept a PR for anyone who has an idea of how to accomplish the fix described above.

joeldenning commented 4 years ago

Another thing - the single-spa core team is proposing a new dom event called statechange that would better facilitate multiple SPA routers on the page at the same time. The root of this URL infinite redirect bug is Angular's handling of multiple routers on the same page, and that statechange event would provide a clearer path for Angular's code to handle this properly. The proposal can be found here - https://github.com/whatwg/html/issues/5562

^ Sharing that not because it will end up being the fix we use immediately, but just that we're trying to make progress on all fronts with this bug. Angular Router is a rather brittle router in that it expects no other code to ever trigger navigation events. However, we'll continue look for a workaround that.

joeldenning commented 4 years ago

Here's the proposed workaround:

Here's an idea of what the code might be for it:

class SingleSpaPlatformLocation extends ɵBrowserPlatformLocation {
  private skipNextPopState: boolean;
  pushState(state: any, title: string, url: string): void {
    this.skipNextPopState = true
    return super.pushState(state, title, url);
  }
  replaceState(state: any, title: string, url: string): void {
    this.skipNextPopState = true
    return super.replaceState(state, title, url);
  }
  onPopState(fn: (event: LocationChangeEvent) => void): void {
    if (this.skipNextPopState) {
      this.skipNextPopState = false;
    } else {
      super.onPopState(event => {
        this.ngZone.run(() => fn(event));
      })
    }
  }
}