WICG / navigation-api

The new navigation API provides a new interface for navigations and session history, with a focus on single-page application navigations.
https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation-api
486 stars 26 forks source link

Multiple navigate handlers, and withholding the 'navigate' event? #89

Open frehner opened 3 years ago

frehner commented 3 years ago

Context/Background

As a maintainer of single-spa, we've had a small discussion about what an "appHistory" version of single-spa would look like.

For context, single-spa is probably pretty unique among JavaScript routers - it acts as a parent router and almost always has multiple children/application routers beneath it. As a part of that structure, single-spa must monkey-patch global functions like popstate (see the section titled "What may be missing/difficult still?" in the discussion above for more detail) so that it can ensure that children/applications don't do unnecessary work for a route that they're not supposed to see.

In other words, single-spa must asynchronously ensure that all applications have been "unmounted" and/or "mounted" before it releases the navigation event to the children/application routers. It does that by monkeypatching those event handlers, and withholding the event from firing until it's sure that children/application are ready.

Proposal

Back to appHistory - we were wondering if there could potentially be a way to "withhold" a navigation event from propagating to other handlers until a later time, ideally after a promise resolves?

For example, maybe it looks like:

window.appHistory.addEventListener('navigate', (evt) => {
  if(evt.canRespond) {
    // no other handlers are called until this promise resolves?
    evt.withhold(singleSpa.triggerAppChange())
  }
})

But I have no idea if that is feasible/possible or not.

Or maybe there's a better way to do something like this?

Workaround without monkeypatching

Without such a capability, one idea I've thought of is the following:

Cancelling the navigate event, rolling back (potentially using #86 ?), waiting for the single-spa's triggerAppChange() promise to resolve, and then navigating again to the original URL?

window.appHistory.addEventListener('navigate', (evt) => {
  if(evt.cancelable && evt.info !== 'single-spa-ready') {
    evt.preventDefault() // cancels the navigation completely
    evt.stopImmediatePropagation() // prevent application routers from seeing the event
    singleSpa.triggerAppChange().then(() => {
      appHistory.navigate(evt.destination.url, {navigateInfo: 'single-spa-ready'})
      // trigger the navigation to the same url again now that everything is mounted?
      // navigateInfo gets turned into evt.info, which we check against above
    })
  }
})

However, one downside of this approach is that single-spa itself can't use evt.respondWith() (unless it would work to pass a promise to that and then cancel the navigate event? I'm not sure) which means that browser UI and assistive technology would be left in the dark.

Otherwise, I think we would be left with monkeypatching the navigate event - which isn't a horrible thing, since it's what we have to do currently. But we thought it would be potentially useful to talk about it.

Thanks 😊

tbondwilkinson commented 3 years ago

Is the intention that other routes on the page be unaware that they are being embedded in another router, and use the native API as if they were a top-level router?

We've kicked around in this design space for a fair bit of time, and couldn't come up with either a killer use case or a killer API. I think the canonical issue where we discussed this was #18, but it's stagnated a bit. I do think before we can consider this API "finished" we need to think about this more deeply to see whether there's something simple usability wise we can do to make this possible.

In your example it seems like you're okay leaking state and URL between routers. That is, one router could read another routers URL parameters. But I don't think in general that would be desirable, and I think a successful design here would probably allow for a more careful segmentation of the URL/state space.

Do you know which routers care about which URL parameters and state keys? Do two routers ever piggyback on the same history entry (that is, they send requests through a central router and those get serialized into one history.push)?

frehner commented 3 years ago

Is the intention that other routes on the page be unaware that they are being embedded in another router, and use the native API as if they were a top-level router?

Yes - all major library routers (Angular, Vue, React, Svelte, etc) work in the single-spa ecosystem, and they don't have to do anything different on their side of things. Additionally, they can coexist at the same time on the same page in some situations.

In your example it seems like you're okay leaking state and URL between routers. That is, one router could read another routers URL parameters. But I don't think in general that would be desirable, and I think a successful design here would probably allow for a more careful segmentation of the URL/state space.

We actually quite explicitly want multiple routers to know when the URL changes, and what it changed to, at the same time. It's possible (though only recommended in very specific situations due to performance) that multiple routers (and possibly even other frameworks!) are active on the page at a time, and they need to know when the url changes so they can render their content for that URL.

But I could be misunderstand what you mean when you say "more careful segmentation of the URL/state space." Sorry if I am! 😊

Do you know which routers care about which URL parameters and state keys?

With single-spa, you generally write very broad URL matches in your activity function / activeWhen config and then the application routers handle the deeper routes; e.g. you tell single-spa the app is active when the path starts with /calendar and then the application router can handle /calendar/day and /calendar/week . If that makes sense?

Do two routers ever piggyback on the same history entry (that is, they send requests through a central router and those get serialized into one history.push)?

Unknowingly, yes. Because single-spa monkeypatches things like history.pushState and history.replaceState and popstate and hashchange to achieve this.

Hopefully I've understood your questions and answered them well enough. Thanks.

tbondwilkinson commented 3 years ago

Ah so you would never have multiple routers active on the same route?

I think there's a few use cases to consider:

  1. One page, multi-router. This is where some sub-DOM wants to be a router, but isn't the root, kind of like an iframe.
  2. Multi-page (same origin), multi-router. This is where different pages may have different routers but they're all the same origin.

I think case #2 might be generally solvable by doing route guards at the top of a navigate-handler (without any new API additions), but I don't fully understand the details of single-spa's use case. Is this something where I should go and read your project documentation and/or code, or do you think you could summarize the model succinctly here?

frehner commented 3 years ago

Ah so you would never have multiple routers active on the same route?

Actually, yes it's possible. Not as common, but it does happen.

I think there's a few use cases to consider:

  1. One page, multi-router. This is where some sub-DOM wants to be a router, but isn't the root, kind of like an iframe.
  2. Multi-page (same origin), multi-router. This is where different pages may have different routers but they're all the same origin.

Yeah. single-spa works in both 1 & 2 situations. 2 is more common, but there are cases where 1 happens as well.

I think case #2 might be generally solvable by doing route guards at the top of a navigate-handler (without any new API additions), but I don't fully understand the details of single-spa's use case. Is this something where I should go and read your project documentation and/or code, or do you think you could summarize the model succinctly here?

I can try to explain, if that helps! Feel free to ask about things I may have missed or you have questions on, though.

single-spa is a "router for frontend microservices." A really overloaded term which can mean a lot of things; for single-spa, all it really means is that we enable you can separate parts of your application into different pieces, which can all be independently deployed but still composed together in the browser as if it were a monolithic build.

Here's an example website using single-spa + Vue along with the source-code for it, but a high-level overview is this:

  1. single-spa is loaded onto the page first
  2. Each application is registered to single-spa, and tells it what routes it is active on. For example, the navbar app is always active, the dogs-dashboard app is active on /view-doggos, and the rate-dogs app is active on /rate-doggos.
  3. On any URL change, single-spa prevents any app from seeing that event (by monkeypatching things like history.pushState()). It then checks each application to see if it should be "active" on the upcoming url or not; it will dynamically import it if it should, and it will unmount it if it shouldn't.
  4. Once all the apps in the correct state, it then "releases" or "re-fires" the original event, so that each application router now knows the active URL and renders itself accordingly

In summary, single-spa acts as a parent router, and at any time there may be n number of applications active on a single given page/URL, and therefore n number of routers active as well.

I tried to keep this pretty high-level, but let me know if you would like details in any area. I hope that overview helps. 😊

tbondwilkinson commented 3 years ago

@domenic This brings up a couple of salient points for me:

  1. We decided on the API "respondWith" but I'm having doubts. In particular, the FetchEvent that has respondWith actually resolves a value, whereas the promise passed to NavigateEvent.prototype.respondWith does not. I wonder whether waitUntil would be a better corollary, since we're telling the browser that the navigate should be extended, rather then that we're changing the navigate resolved value itself. Or since neither of these are a precise fit, maybe we should use a different name altogether?
  2. I think we haven't fully considered the case of multiple navigate event listeners. It seems reasonable that the listeners would be called in the order in which they were registered. But what happens if an earlier event listener calls respondWith? Should the later event listeners not get the navigate event at all? Should they be called synchronously, or after the respondWith resolves? Should they be called asynchronously after the respondWith resolves? I think the prior art here with the FetchEvent is that all event listeners are called synchronously, regardless of what the previous handler did. And I think unlike the fetch API, it's reasonable to assume that someone could accidentally load two sets of code that both register navigate handlers.

I think you're right here Anthony, and we should consider a new API addition, and I think it's precisely what you've proposed. You called it withhold but I'm wondering whether we should use the term block because it clearly shows that something is being delayed, and has precedence in other systems (like Node). Specifically:

appHistory.addEventListener('navigate', (e) => {
  if (!e.canRespond) return;
  e.blockUntil(singleSpa.triggerAppChange());
});

This would be separate from respondWith (or whatever we call it) which is a signal to the browser about the duration of the navigate itself. Instead, this is a mechanism for event listeners to communicate with one another. There are probably other such mechanisms we could consider to meet the same purpose.

Personally I think it's a little disappointing that we can't just use the return value of an event listener to delay the calling of the next event listener, but that ship might have long since sailed. But imagine:

appHistory.addEventListener('navigate', async (e) => {
  if (!e.canRespond) return;
  await singleSpa.triggerAppChange();
});

Feels pretty elegant, and makes the "pause" and "resume" implicit.

I wonder if it's a little confusing if you combine Promise return (or blockUntil) and respondWith, however:

appHistory.addEventListener('navigate', async (e) => {
  if (!e.canRespond) return;
  e.respondWith(Promise.resolve());
  await singleSpa.triggerAppChange();
});

I think the reasonable behavior here is that the navigate is not "finished" until all respondWith() promises have resolved and every promise returned from an event listener (or blockUntil) is resolved.

tbondwilkinson commented 3 years ago

FWIW I think there's a reasonable other path that we could consider going down (in addition, or instead of), and it's to register event listeners for only a subset of URL/state parameters. This is more along the lines of what I was describing in #18

appHistory.registerScope('foo', {urlKeys: ['a', 'b', 'c']});
const scopedAppHistory = appHistory.getScope('foo');
scopedAppHistory.addEventListener('navigate', (e) => {
  // Called whenever an entry added by this scopedAppHistory is involved in a navigate.
});

// At this point, the only thing in scopedAppHistory.entries() is the root entry.

// This only updates the URL keys that were registered, and the state does not overwrite the global appHistory state.
// appHistory gets an update about this, as if it were a normal URL change. I think scoped navigate handlers would be
// triggered first? But maybe they don't interact? 
// Instead of passing a full URL, you pass a Map with url keys and values.
scopedAppHistory.navigate(urlParams: Map<string, string>, {state});
domenic commented 3 years ago

Back to appHistory - we were wondering if there could potentially be a way to "withhold" a navigation event from propagating to other handlers until a later time, ideally after a promise resolves?

It kind of depends on what you mean.

The latter is how we've mostly designed this API. But saying

Yes - all major library routers (Angular, Vue, React, Svelte, etc) work in the single-spa ecosystem, and they don't have to do anything different on their side of things.

indicates it probably won't work for your case.

We decided on the API "respondWith" but I'm having doubts.

I've never had a strong attachment to the name respondWith(). Indeed you don't supply an actual Response object like in cross-document service worker navs that go to a new HTTP response; instead you manipulate your page's state in order to update it for the new "response". To me these are analogous enough, and the name is reasonable enough, but I'm open to other ideas.

I think we haven't fully considered the case of multiple navigate event listeners.

Well, from my perspective we've considered it, and decided it's rare that it would work in a useful way :). Service workers historically went through a similar thing; they want to use some features and semantics of events, but they don't really work that well with multiple listeners. They ended up settling on events anyway. /cc @annevk

To answer your specific questions:

But what happens if an earlier event listener calls respondWith?

As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.

Should the later event listeners not get the navigate event at all?

We could spec that, and it might be a good idea.

Should they be called synchronously, or after the respondWith resolves?

Multiple event listeners are always called synchronously; this is a feature of all EventTargets.

And I think unlike the fetch API, it's reasonable to assume that someone could accidentally load two sets of code that both register navigate handlers.

To me they seem analogous: in both cases, if you have two parts of your app trying to handle navigations or fetches, something has gone wrong.

I think you're right here Anthony, and we should consider a new API addition, and I think it's precisely what you've proposed. You called it withhold but I'm wondering whether we should use the term block because it clearly shows that something is being delayed, and has precedence in other systems (like Node).

Can you explain what this would do? Recall that we can't actually delay the navigation; we need to update location.href synchronously. We also can't asynchronously delay other event handlers; firing an event means running all of its handlers, synchronously, until one of them calls stopImmediatePropagation() or you're done.

So, would the semantics here be something like preventDefault() plus re-issuing the navigation after a delay, which would fire the navigate event again?

Instead, this is a mechanism for event listeners to communicate with one another. There are probably other such mechanisms we could consider to meet the same purpose.

My general suggestion is that frameworks should try to move this down a level, and have only one navigate listener which then uses framework-specific mechanisms to communicate among "sub-listeners" which themselves don't directly listen to navigate. Event listeners on the web platform have very defined semantics and not a lot of room for introducing new ones.

I realize that might be difficult for single-spa though if they want to work with unmodified third-party routers. Then again, it sounds like maybe they can make it work via the power of monkeypatching?

FWIW I think there's a reasonable other path that we could consider going down (in addition, or instead of), and it's to register event listeners for only a subset of URL/state parameters.

Service workers has struggled with this for years, and unfortunately not gotten anywhere. Their issue is https://github.com/w3c/ServiceWorker/issues/1373. That said, their constraints are quite different (e.g. for them they want to avoid running any JS code at all), so maybe there's something we could do which works better. I do think it's tricky though; it feels more like a framework-level solution that I'd prefer to add later after seeing what frameworks come up with themselves.

frehner commented 3 years ago

I know the above comment wasn't addressed to me, but I put my thoughts on it below. Hopefully that's ok; I don't mean it to be speaking on Tom's behalf - I'm sure he'll have his own answers/responses - it's just me sharing my thoughts.

The latter is how we've mostly designed this API. But saying

Yes - all major library routers (Angular, Vue, React, Svelte, etc) work in the single-spa ecosystem, and they don't have to do anything different on their side of things.

indicates it probably won't work for your case.

Yes, agreed.

To answer your specific questions:

But what happens if an earlier event listener calls respondWith?

As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.

Oh I had no idea on this - is this in the official spec? Because I think I've missed it in the explainer doc + examples if it's in there.

I was always under the assumption that every handler could call respondWith() regardless of what happened in previous handlers (barring preventDefault() or something). I'll have to update the polyfill I'm working on to behave in this manner.

This behavior actually seems like it could open the door to some brittleness - router libraries could reasonable assume that you can just call respondWith() all the time (since in most cases they're the only one listening to navigate) and not knowing that some other 3rd party code or userland code could make calling that function throw an error.

Should the later event listeners not get the navigate event at all?

We could spec that, and it might be a good idea.

Yeah, that can help with the brittleness I mentioned above. Or another option would be to set evt.canRespond = false on later handlers so that they know they can't call respondWith()?

And I think unlike the fetch API, it's reasonable to assume that someone could accidentally load two sets of code that both register navigate handlers.

To me they seem analogous: in both cases, if you have two parts of your app trying to handle navigations or fetches, something has gone wrong.

This seems slightly weird to me - that you set this event up just like any other EventTarget where multiple handlers can be registered, but then at the same time kind of say that you really only support one handler? Or am I misunderstanding you here?

I also think single-spa is an example of where multiple handlers are explicitly set up, without anything having "gone wrong" 🙂. Perhaps, with some investigation, there could be other "good" examples of multiple handlers found?

I think you're right here Anthony, and we should consider a new API addition, and I think it's precisely what you've proposed. You called it withhold but I'm wondering whether we should use the term block because it clearly shows that something is being delayed, and has precedence in other systems (like Node).

Can you explain what this would do? Recall that we can't actually delay the navigation; we need to update location.href synchronously. We also can't asynchronously delay other event handlers; firing an event means running all of its handlers, synchronously, until one of them calls stopImmediatePropagation() or you're done.

So, would the semantics here be something like preventDefault() plus re-issuing the navigation after a delay, which would fire the navigate event again?

Yes, I think (in an ideal, perfect world) that it would act this way. But I don't know if that's something that could reasonably happen or not.

Instead, this is a mechanism for event listeners to communicate with one another. There are probably other such mechanisms we could consider to meet the same purpose.

My general suggestion is that frameworks should try to move this down a level, and have only one navigate listener which then uses framework-specific mechanisms to communicate among "sub-listeners" which themselves don't directly listen to navigate. Event listeners on the web platform have very defined semantics and not a lot of room for introducing new ones.

I realize that might be difficult for single-spa though if they want to work with unmodified third-party routers. Then again, it sounds like maybe they can make it work via the power of monkeypatching?

Yeah, again, if that's what it comes down to, then I think that'll be ok. We just thought it would be nice to at least have this conversation and see if there is a "better" way to do this.

Thank you for your response and time!

frehner commented 3 years ago

To answer your specific questions:

But what happens if an earlier event listener calls respondWith?

As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.

The more I've thought about this part, the more I disagree with it. It essentially puts us back to the current status quo, which is that only one router per page is allowed and any third party code must somehow tie into that router.

To not clutter this discussion about the blockUntil/withhold proposal, I think I'll create a new issue to just discuss that part

[edit] reading your response below now - we posted nearly at the same time :D

domenic commented 3 years ago

Oh I had no idea on this - is this in the official spec? Because I think I've missed it in the explainer doc + examples if it's in there.

Yeah, see https://wicg.github.io/app-history/#dom-apphistorynavigateevent-respondwith

Your reply has some other good points but let me take a step back and try to summarize.

tbondwilkinson commented 3 years ago

I largely agree with Domenic's assessment here.

I think it depends on how common it is. If it's very special libraries like single-spa, whose goal is to allow multiple unmodified frameworks to run in the same web app, then maybe it's OK. If it's a more general thing that many different frameworks would like to do, then perhaps we should reconsider. But my instinct is that most frameworks do not expect (and should not expect) their users to use appHistory directly; instead they will install a single framework-wide navigate handler, and use framework-specific mechanisms to delegate work to various app components.

I think it's probably more common for there to be multiple routers on a SPA than we might expect. And perhaps additionally there's something to be said, even in applications that are in control, for having a non-monolithic event listener. But I do think that this is the struggle with this feature - we need to make sure this use case is real, rather than specialized.

In other words, if delaying action until an async determination is made is a key goal of app history, then we should just delay the whole navigation to be asynchronous, instead of doing something kludgy like telling people to synchronously intercept or cancel the navigation but later reissue it

If we go down this route, I think a more fruitful direction would be a beforenavigate listener, rather than making navigate itself asynchronous and only certain APIs would trigger the beforenavigate (e.g. location.href would not). There are some cases where it's probably okay for the navigate to be asynchronous, but lots of cases where it's probably not.

Of the smaller QOL improvements: I think allowing multiple calls to event.respondWith seems reasonable to me, but it begs the question whether you'd want to add the ability to get the current promise, so that you could .then() it, rather than just add to the Promise.all

Change the updating of location.href and appHistory.current to synchronously happen after all handlers have finished running (if respondWith() was called by any of them), instead of happening synchronously as part of respondWith(). This helps avoid the problem where future handlers see appHistory.current === appHistory.destination.

Oh, this seems like something we'd want to do, regardless of the outcome here.

frehner commented 3 years ago

The problem comes about when you can only determine asynchronously which URLs you want to handle. This appears to be the single-spa case.

slight correction here: single-spa can synchronously determine which apps should be active. However, it then needs to be able to make async changes (e.g. dynamically import an app) before other navigate handlers are notified.

I think it's probably more common for there to be multiple routers on a SPA than we might expect. And perhaps additionally there's something to be said, even in applications that are in control, for having a non-monolithic event listener. But I do think that this is the struggle with this feature - we need to make sure this use case is real, rather than specialized.

I think this is largely a chicken-and-egg problem; the vast majority of cases you’ll find that only one router is on the page. But if you dig into WHY there’s only one router, a lot of the reasons will come down to “there’s no easy way to have multiple routers on the page at the same time.”

Allowing multiple routers can open the door to much easier 3rd party integrations; think of a Widget or Custom Element that perhaps need to know the route (and maybe occasionally respond with their own promise to get more data). As well as a single-spa situation that has multiple applications at the same time.

frehner commented 3 years ago

Forked the discussion about allowing multiple routers/event handlers to #94; I think that is a valuable and important use case, whereas the withhold()/blockUntil() proposal here - while nice for a library I maintain - is probably quite a bit more niche and should be considered separately.

jjkola-eskosystems commented 2 years ago

I have made a library which listens on the navigate event and I would like to see that it will receive the event regardless what the application which uses the library does and/or what order the events are listened.

tbondwilkinson commented 1 year ago

I think there's still a discussion to be had around how exactly multiple routers on the page should function if they are nested within the same-document, and a navigate should update multiple parts of the page.