Skip to content
This repository was archived by the owner on Aug 21, 2026. It is now read-only.
This repository was archived by the owner on Aug 21, 2026. It is now read-only.

Will the current transitionWhile() design of updating the URL/history entry immediately meet web developer needs? #66

Closed

Description

@domenic

As discussed previously in #19 (and resolved in #46), intercepting a navigation using the navigate event and converting it into a same-document navigation will synchronously update location.href, navigation.currentEntry, and so on. I think this is on solid ground per those threads; delaying updates is problematic. (EDIT: further discussion, starting around #66 (comment), reveals this is not quite settled.)

However, I wonder about the user experience of when the browser URL bar should update. The most natural thing for implementations will be to have the URL bar match location.href, and update immediately. But is this the experience web developers want to give their users?

I worry that perhaps some single-page apps today update the URL bar (using history.pushState()) later in the single-page-navigation lifecycle, and that they think this is important. Such apps might not want to use the app history API, if doing so changed the user experience to update the URL bar sooner.

The explainer gestures at this with

TODO: should we give direct control over when the browser UI updates, in case developers want to update it later in the lifecycle after they're sure the navigation will be a success? Would it be OK to let the UI get out of sync with the history list?

and this is an issue to gather discussion and data on that question. It would be especially helpful if there were web application or framework authors which change the URL in a delayed fashion, and could explain their reasoning.

A counterpoint toward allowing this flexibility is that maybe standardizing on the experience of what SPA navs feel like would be better for users.

Activity

  1. added
    additionA proposed addition which could be added later without impacting the rest of the API
    on Mar 9, 2021
  2. kenchris commented on Mar 16, 2021

    @kenchris

    I will try to reach out to some of my framework friend and see if they will leave any feedback

  3. manekinekko commented on Mar 16, 2021

    @manekinekko

    Thank you @kenchris for sharing this.

    @mhevery @IgorMinar @alxhub @mgechev @@pkozlowski-opensource how is this going to affect Angular?

  4. manekinekko commented on Mar 16, 2021

    @manekinekko

    Also adding my friend @posva from the Vue.js team. Any feedback on this, Eduardo?

  5. posva commented on Mar 22, 2021

    @posva
    Contributor

    I think this is on solid ground per those threads; delaying updates is problematic.

    This is true. Being able to get the updated location right after pushState() in all browsers is easier to handle at framework level.

    respondWith() design seems to be good to be able to use it. I would need to play with it to better know of course.

    From Vue Router perspective, all navigations are asynchronous, meaning calling router.push('/some-location') will update the URL at least one tick later due to navigation guards (e.g. data fetching, user access rights, etc). This also applies for history.back() (or clicking the back buttons which seems to be the same) but in that scenario, the URL is updated immediately, and it's up to the router framework to rollback the URL if a navigation guard cancels the navigation. This means the URL changes twice in some cases (e.g. an API call to check for user-access rights). From what I understood with #32 (comment), this shouldn't be a problem anymore as we could avoid changing the URL until we are sure we can navigate there.

  6. atscott commented on Mar 22, 2021

    @atscott
    Contributor

    I don't have much additional to add for Angular beyond what @posva mentioned for Vue. Angular's router operates in much the same manner.

    We do provide the ability to update the URL eagerly via the urlUpdateStrategy. The default is 'deferred' though, which means for most cases, the URL is update after guards run, like @posva mentioned for Vue.

    We also have the same issue with the history.back/back buttons/url bar updates and we specifically don't do a good job of rolling back the URL (angular/angular#13586 (comment)) - this would be easy to fix with the app history API.

    I'm in the process of going through the proposal and evaluating how we would integrate it into the Angular router.

  7. kenchris commented on Apr 20, 2021

    @kenchris

    Hi there @atscott @posva can you explain me what those guards are and how they work? Asking for the @w3ctag

    How long are these commonly deferred? Are we taking about microseconds here or can they be deferred indefinitely

  8. bathos commented on Apr 20, 2021

    @bathos

    I worry that perhaps some single-page apps today update the URL bar (using history.pushState()) later in the single-page-navigation lifecycle, and that they think this is important. Such apps might not want to use the app history API, if doing so changed the user experience to update the URL bar sooner.

    I’m in that camp, but it’s not about the URL bar for me, it’s about ensuring APIs like location.href and node.baseURI are in sync with the effective current state of the application. I’ve seen bugs arise during the brief time between the start and end of async resolution of a new application state because of components treating the current URL as a source of truth*. Since switching to unilaterally performing an initial URL rollback before async resolution a few years ago, I’ve never seen these problems again.

    Since the URL bar’s contents aren’t accessible to JS, it’s the one aspect of this which doesn’t matter to me — whether it updates optimistically wouldn’t impact whether there is an observable misaligned transitional state.

    (It seems I want the exact opposite of what’s been described, if I understand right? — cause for me it’s “not delaying updates [to location.href, etc] is problematic.” Immediately-update-code-observable-location behavior might prevent us from making meaningful use of respondWith, though other aspects of AppHistory would remain useful to us regardless.)

    How long are these commonly deferred? Are we taking about microseconds here or can they be deferred indefinitely

    While I can’t answer for the folks above, brief unscientific testing suggests that in our case it falls between 0 and 150ms (w/ navigator.connection.effectiveType 4g). The timing depends on what dependent API requests are involved, if any. It seems fair to describe it as some specific fraction of the time it would take to load a whole new document if we were doing SSR since it’s a subset of the same work that would be done if we were (fetch x from the database, etc).

    * Ex: Relative query param links, e.g. href="?page=2" from templates associated with the “old state” (which generally would not be replaced until the route resolution completes) will link to the wrong thing during the transition.

  9. posva commented on Apr 20, 2021

    @posva
    Contributor

    Hi there @atscott @posva can you explain me what those guards are and how they work? Asking for the @w3ctag

    How long are these commonly deferred? Are we taking about microseconds here or can they be deferred indefinitely

    Sure, they are explained here and they usually last between 16ms or a few seconds. They can be deferred indefinitely but they shouldn't.

  10. bathos commented on Jun 28, 2021

    @bathos

    Currently playing with the Chromium impl, the required pattern to achieve the behavior we currently have (location updates after successful state transition) is roughly:

    • on navigate, branch based on info value that may or may not be some sentinel:
      • sentinel absent and canRespond:
        • create & dispatch custom event (e.g. "navigaterequest"). associate orig destination / impl similar respondWith API
        • if custom respondWith called:
          • preventDefault / stopprop original event
          • await custom respondWith promise
            • if rejected, dispatch custom navigateerror (and do not commit the failed change to url)
            • otherwise, create navigate options based on original destination + promise result value (e.g. url canonicalization) and call navigate() with the sentinel info value to "commit" it

    The built-in respondWith needs to be ignored in this model - the only "response" action is to preventDefault - but the end result is a similar API surface. It does seem like an improvement because it involves less code than current History-based solutions, but the misalignment is also very stark.

  11. domenic commented on Jun 28, 2021

    @domenic
    CollaboratorAuthor

    Very interesting! The fact that you are able to mostly emulate the delayed-URL-update behavior based on the current primitives is a good sign. It feels similar to #124 in that maybe we can add it to the API on top of what exists, so that anyone who wants to achieve a similar experience can just use app history directly instead of needing to get all the edge cases right in a way similar to what you describe.

  12. bathos commented on Jul 28, 2021

    @bathos

    Edit: This was a previously question I asked due to mistaking a behavior change related to location updates for one which would implement the behavior I’m seeking. But I got ahead of myself and can answer my own question about it: the bit I was clearly not paying attention to was that it said “after all handlers have finished running,” so no, the behavior did not change in the way I thought. Leaving the comment intact below to avoid confusion if people got emails.

    Original comment/question

    @domenic I just saw #94 (comment):

    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. Edit: this was done in Move URL/history updating out of respondWith() #103.

    The last of these is done. I'm interested in feedback on which, if any, of the first three of these we should do.

    Is it correct to interpret this as saying event.methodCurrentlyKnownAsRespondWith(x) behavior has been changed to now defer the update of observable location/current until x settles?

    Previous comments had given me the impression that location/current updating at the start rather than the end of a transition was a settled question. Our last two comments here are from a month later than the comment I quoted, too — so I’m not sure if maybe I’m misinterpreting what I read there.

    If this behavior is set to change, the cancel-initially-but-repeat-the-navigation-again-later dance I described in my prior comment won’t be needed as far as I can tell. Since I think this was the only challenging disconnect between AppHistory’s behavior and the routing behavior we currently implement and want to preserve, if location update deferral is real now, it likely means we’ll be able to use the appHistory API “bare”!

    I am dizzy imagining the possible thundering satisfication of just outright deleting ten thousand pounds of terrifying tower-of-hacks-and-monkey-patches History mediation code without leaving one trace ... gonna go lie down whew

  13. domenic commented on May 23, 2022

    @domenic
    CollaboratorAuthor

    From @jakearchibald in #232

    With MPA:

    1. Begin fetching
    
    2. Time passes
    
    3. Fetch complete
    
    4. Switch to new page - URL changes here
    

    With transitionWhile:

    1. Begin fetching
    
    2. `transitionWhile` called - URL changes here
    
    3. Time passes
    
    4. Fetch complete
    
    5. Alter DOM
    

    This means old-page content is being shown between steps 2-5, but with the new URL. This presents issues for relative URLs (including links, images, fetch()), which won't behave as expected.

    It feels like URL changing should be deferred until the promise passed to transitionWhile resolves, perhaps with an optional commitURLChange() method to perform the swap sooner.

    Along with the URL issue, there's also the general issue of the content not matching the URL.

    We've known for some time that the content will not match the URL. This thread (as well as various offline discussions) have various framework and router authors indicating that is OK-ish, except for @bathos who is working around it.

    The novel information for me here is Jake's consideration for relative URLs. It's not clear to me exactly what goes wrong here; surely for new content, you want to fetch things relative to the new URL? But I can imagine maybe that's not always what's assumed.

    Anyway, toward solutions: the big issue here is that any delay of the URL needs to be opt-in because it breaks previous platform invariants. Consider this:

    navigation.addEventListener("navigate", e => {
      e.transitionWhile(delay(1000), { delayURLUpdate: true });
    });
    
    console.log(location.href); // "1"
    location.hash = "#foo";
    console.log(location.href); // Still "1"!!!

    Similarly for all other same-document navigation APIs, like history.pushState(), which previously performed synchronous URL updates.

    As long as we have such an opt-in, we can probably make this work. The page just has to be prepared for same-document navigation APIs to have delayed effects.

  14. jakearchibald commented on May 23, 2022

    @jakearchibald
    navigation.addEventListener("navigate", e => {
      e.transitionWhile(delay(1000), { delayURLUpdate: true });
    });
    
    console.log(location.href); // "1"
    location.hash = "#foo";
    console.log(location.href); // Still "1"!!!

    fwiw, I think it'd be fine if fragment changes are guaranteed to be synchronous. Only navigations that would be cross-document can have their URL update delayed, matching MPA behaviour.

  15. jakearchibald commented on May 24, 2022

    @jakearchibald

    Here are a couple of demos that might help:

    https://deploy-preview-13--http203-playlist.netlify.app/ - requires Canary and chrome://flags/#document-transition. There's a two second timeout between clicking a link and the DOM updating.

    This was supposed to be my "works fine" demo because it doesn't use relative links. However, I noticed that if I click one thumbnail, then click another within the two seconds, the transition isn't quite right.

    This is because my code is like:

    navigation.addEventListener('navigate', (event) => {
      event.transitionWhile(
        createPageTransition({
          from: navigation.currentEntry.url,
          to: event.destination.url,
        })
      );
    });

    Because the URL commits before it's ready, on the second click the from doesn't represent the content. To work around this, I guess I'd have to store a currentContentURL somewhere in the app, and try to keep that in sync with the displayed content.

    https://deploy-preview-14--http203-playlist.netlify.app/ - here's an example with relative URLs. If you click one thumbnail then another, you end up on a broken URL. Even if you click the same thumbnail twice, which some users will do accidentally.

    My gut feeling is that relative URLs on links is uncommon these days, but there are other types of relative URL that will catch folks out here. Eg, fetching ./data.json and expecting it to be related to the content URL.

  16. 63 remaining items

  17. domenic commented on Mar 3, 2023

    @domenic
    CollaboratorAuthor

    Hey all!! @natechapin is working on a prototype of this in Chromium, and the explainer is available for review in #259 . You might enjoy taking a look!

  18. natechapin commented on Mar 28, 2023

    @natechapin
    Collaborator

    Hey everyone,

    @domenic and I have been bikeshedding names in the code review protoype, and I wanted to get some additional feedback.

    Based on @jakearchibald's suggestions earlier in this thread, I had used commit values of 'immediate' and 'manual'. @domenic suggested that we change 'manual' to 'after-transition'. This is for consistency with focus and scroll behavior options, and to emphasize that if navigateEvent.commit() is not called, then the commit will occur when the transition ends (in the prototype, this is just before navigatesuccess fires).

    Does anyone have thoughts on the clearest name?

  19. bathos commented on Mar 28, 2023

    @bathos

    +1 for 'after-transition'. Apart from communicating more than “manual”, it makes more sense in an enum alongside 'immediate' because both phrases are “temporal” choices.

  20. natechapin commented on Mar 29, 2023

    @natechapin
    Collaborator

    Thanks for the feedback! One more question:

    If the commit is deferred, and the handler() returns a rejected Promise before navigateEvent.commit() is called, the prototype treats that as a cancelled navigation. But we want to avoid any situation in which this allows a backdoor way to cancel a navigation (e.g., navigateEvent.intercept({ commit: 'after-transition', handler: () => Promise.reject() }); to cancel an otherwise uncancellable traversal in order to back-trap a user).

    Our proposal is to forbid 'after-transition' when navigateEvent.cancelable is false. The question is: what should navigateEvent.intercept({ commit: 'after-transition' }); do in that case? I see two options:

    • intercept() throws, and no intercept occurs.
    • 'after-transition' is ignored with a console warning. The navigation is still intercepted, but the commit is 'immediate' by default.

    Note that this is a relatively rare situation, because it requires the navigation to be cancellable but also interceptable, which is only the case for uncancellable same-document traversals. That means you're not going to accidentally go cross-document if we choose the first option.

    Thoughts?

  21. atscott commented on Mar 29, 2023

    @atscott
    Contributor

    'after-transition' is ignored with a console warning. The navigation is still intercepted, but the commit is 'immediate' by default.

    I think this is probably more reasonable than throw, right? You might still want to intercept to get the other benefits of the transition (loading spinner, scroll restoration, etc.)

  22. natechapin commented on Mar 29, 2023

    @natechapin
    Collaborator

    'after-transition' is ignored with a console warning. The navigation is still intercepted, but the commit is 'immediate' by default.

    I think this is probably more reasonable than throw, right? You might still want to intercept to get the other benefits of the transition (loading spinner, scroll restoration, etc.)

    I think the main concern is whether unexpectedly having an 'immediate' commit when you asked for an 'after-transition' will break application logic.

  23. tbondwilkinson commented on Mar 29, 2023

    @tbondwilkinson
    Contributor

    I guess I have a slight preference for throwing an error, but I think it's also reasonable to intercept without throwing an error.

  24. atscott commented on Mar 30, 2023

    @atscott
    Contributor

    Ah, sorry I forgot that this is actually a property on the intercept options. I think it probably makes sense to throw in that case.

  25. domenic commented on Apr 28, 2023

    @domenic
    CollaboratorAuthor

    For anyone watching this thread, note that the explainer is updated to describe this new ability, and this new ability is available in Chromium >= 114.0.5696.0 behind the experimental web platform features flag.

    This issue remains open at least until the spec is updated, which hasn't happened yet.

    Since this is a tricky area, we're looking for web developer testing and validation that what we've built here is good and usable. Please play with it behind a flag and let us know if it's something we should ship!

  26. rd3k commented on Jun 23, 2023

    @rd3k

    I've been using commit: 'after-transition' in a test application. So far so good! I'm finding I want to use it over 'immediate', so hope it makes the cut.

  27. absurdprofit commented on Aug 16, 2024

    @absurdprofit
    Contributor

    I've got a router library that has gesture navigation and delaying the commit is super useful since you can technically "cancel" the gesture. It's a lot more straightforward to simply reject the promise returned in the handler passed to intercept rather than having to patch the history (traverse back to the previous entry) after the gesture to rollback the navigation.

    Untitledvideo-MadewithClipchamp-ezgif com-video-to-gif-converter

    Note that for Safari on iOS this is much closer to the default UA behaviour when gesture navigating, so this might be a pattern that gets replicated a lot.

  28. sebmarkbage commented on Jan 8, 2025

    @sebmarkbage

    We're hitting this issue with adopting Navigation API in Next.js as well. Without it's a breaking change for us. Since the current behavior is to call pushState for new navigations after. There's many benefits to this as has been outlined above.

  29. domenic commented on Feb 27, 2025

    @domenic
    CollaboratorAuthor

    Let me close this issue as deferred commit is in the explainer and is in the process of being specced. You can also follow Chromium's shipping progress on ChromeStatus.

    Please feel free to open new issues if you experiment with the feature and find that it doesn't meet your needs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    additionA proposed addition which could be added later without impacting the rest of the API

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions