From e2ee9e8fb7df7edf4993462ec872b0a40c2ec74a Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 15 Jun 2022 17:12:10 -0400 Subject: [PATCH 1/4] Revamp intercepted-navigation scroll handling This changes the default behavior of intercepted navigations with respect to scrolling in the following ways: * Scroll restoration is performed on reloads, not just traverses. * For pushes and replaces, we either scroll to the top or scroll to the fragment. This change requires some updated API surface: the scrollRestoration option to intercept() has become scroll, and the navigateEvent.restoreScroll() method has become navigateEvent.scroll(). The latter method now has more capabilities, working to give browser-default-like behavior for "push", "replace", and "reload" in addition to "traverse". Similar to before, all of this can be opted out of by using scroll: "manual". Closes #237 by giving an easy default behavior for SPA navigations with hashes. Closes #231 by making "push", "replace", and "reload" behave by default in an MPA-like manner with respect to scroll position, just like "traverse" does. --- README.md | 76 +++++++++++++++++++++++++++++--------------- navigation_api.d.ts | 3 +- spec.bs | 77 +++++++++++++++++++++++++++------------------ 3 files changed, 98 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 9ecd6ee..c1c3e0c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ backButtonEl.addEventListener("click", () => { - [Accessibility technology announcements](#accessibility-technology-announcements) - [Loading spinners and stop buttons](#loading-spinners-and-stop-buttons) - [Focus management](#focus-management) + - [Scrolling to fragments and scroll resetting](#scrolling-to-fragments-and-scroll-resetting) - [Scroll position restoration](#scroll-position-restoration) - [Transitional time after navigation interception](#transitional-time-after-navigation-interception) - [Example: handling failed navigations](#example-handling-failed-navigations) @@ -611,48 +612,71 @@ We can also extend the `focusReset` option with other behaviors in the future. H - `focusReset: "immediate"`: immediately resets the focus to the `` element, without waiting for the promise to settle. - `focusReset: "two-stage"`: immediately resets the focus to the `` element, and then has the same behavior as `"after-transition"`. +#### Scrolling to fragments and scroll resetting + +Current single-page app navigations leave the user's scroll position where it is. This is true even if you try to navigate to a fragment, e.g. by doing `history.pushState("/article#subheading")`. The latter has caused significant pain in client-side router libraries; see e.g. [remix-run/react-router#394](https://github.com/remix-run/react-router/issues/394), or the manual code that is needed to handle this case in [Vue](https://sourcegraph.com/github.com/vuejs/router/-/blob/src/scrollBehavior.ts?L81-140), [Angular](https://github.com/angular/angular/blob/main/packages/router/src/router_scroller.ts#L76-L77), [React Router Hash Link](https://github.com/rafgraph/react-router-hash-link/blob/main/src/HashLink.jsx), and others. + +With the navigation API, there is a different default behavior, controllable via another option to `navigateEvent.intercept()`: + +- `e.intercept({ handler, scroll: "after-transition" })`: the default behavior. After the promise returned by `handler` fulfills, the browser will attempt to scroll to the fragment given by `e.destination.url`, or if there is no fragment, it will reset the scroll position to the top of the page (like in a cross-document navigation). +- `e.intercept({ handler, scroll: "manual" })`: the browser will not change the user's scroll position, although you can later perform the same logic manually using `e.scroll()`. + +The `navigateEvent.scroll()` method could be useful if you know you have loaded the element referenced by the hash, or if you know you want to reset the scroll position to the top of the document early, before the full transition has finished. For example: + +```js +if (navigateEvent.navigationType === "push" || navigateEvent.navigationType === "replace") { + navigateEvent.intercept({ + scroll: "manual", + async handler() { + await fetchDataAndSetUpDOM(navigateEvent.url); + navigateEvent.scroll(); + + // Note: navigateEvent.scroll() will update what :target points to. + await fadeInTheScrolledToElement(document.querySelector(":target")); + } + }); +} +``` + +If you want to only perform the scroll-to-a-fragment behavior, and not reset the scroll position to the top if there is no matching fragment, then you can use `"manual"` combined with only calling `navigateEvent.scroll()` when `(new URL(navigateEvent.destination.url)).hash` points to an element that exists. + +Note that the discussion in this section applies only to `"push"` and `"replace"` navigations. For the behavior for `"traverse"` and `"reload"` navigations, read on... + #### Scroll position restoration -A common pain point for web developers is scroll restoration during traversal (back/forward) navigations. The essential problem is that scroll restoration happens unpredictably, and often at the wrong times. For example: +A common pain point for web developers is scroll restoration during `"traverse"` and `"reload"` navigations. The essential problem is that scroll restoration happens unpredictably, and often at the wrong times. For example: - The browser tries to restore the user's scroll position, but the application logic is still setting up the DOM and the relevant elements aren't ready yet. - The browser tries to restore the user's scroll position, but the page's contents have changed and scroll restoration doesn't work that well. (For example, going back to a listing of files in a shared folder, after a different user deleted a bunch of the files.) - The application needs to perform some measurements in order to do a proper transition, but the browser does scroll restoration during the transition, which messes up those measurements. ([Demo of this problem](https://nifty-blossom-meadow.glitch.me/legacy-history/transition.html): notice how when going back to the grid view, the transition sends the square to the wrong location.) -Currently the browser provides two options: performing scroll restoration automatically, or disabling it entirely with `history.scrollRestoration = "manual"`. The new navigation API gives us an opportunity to provide some intermediate options to developers, at least for the case of same-document transitions. We do this via another option to `intercept()`: +The same `scroll` option to `navigateEvent.intercept()` that we described above for `"push"` and `"replace"` navigations, similarly controls scroll restoration for `"traverse"` and `"reload"` navigations. And similarly to that case, using `intercept()` opts you into a more sensible default behavior: -- `e.intercept({ handler, scrollRestoration: "after-transition" })`: the default behavior. The browser delays its scroll restoration logic until `promise` fulfills; it will perform no scroll restoration if the promise rejects. If the user has scrolled during the transition then no scroll restoration will be performed ([like for multi-page navs](https://neat-equal-cent.glitch.me/)). -- `e.intercept({ handler, scrollRestoration: "manual" })`: The browser will perform no automatic scroll restoration. However, the developer can use the below API to get semi-automatic scroll restoration, or can use [`window.scrollTo()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo) or similar APIs to take full control. +- `e.intercept({ handler, scroll: "after-transition" })`: the default behavior. The browser delays its scroll restoration logic until `promise` fulfills; it will perform no scroll restoration if the promise rejects. If the user has scrolled during the transition then no scroll restoration will be performed ([like for multi-page navs](https://neat-equal-cent.glitch.me/)). +- `e.intercept({ handler, scroll: "manual" })`: The browser will perform no automatic scroll restoration. However, the developer can use the `e.scroll()` API to get semi-automatic scroll restoration, or can use [`window.scrollTo()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo) or similar APIs to take full control. -When using `scrollRestoration: "manual"`, the `e.restoreScroll()` API is available. This will perform the browser's scroll restoration logic at the specified time. This allows cases that require precise control over scroll restoration timing, such as a non-broken version of the [demo referenced above](https://nifty-blossom-meadow.glitch.me/legacy-history/transition.html), to be written like so: +For `"traverse"` and `"reload"`, the `navigateEvent.scroll()` API performs the browser's scroll restoration logic at the specified time. This allows cases that require precise control over scroll restoration timing, such as a non-broken version of the [demo referenced above](https://nifty-blossom-meadow.glitch.me/legacy-history/transition.html), to be written like so: ```js -navigateEvent.intercept({ - async handler() { - await fetchDataAndSetUpDOM(); - navigateEvent.restoreScroll(); - await measureLayoutAndDoTransition(); - }, - scrollRestoration: "manual" -}); +if (navigateEvent.navigationType === "traverse" || navigateEvent.navigationType === "reload") { + navigateEvent.intercept({ + scroll: "manual" + async handler() { + await fetchDataAndSetUpDOM(); + navigateEvent.scroll(); + await measureLayoutAndDoTransition(); + }, + }); +} ``` -Some details: - -- The `scrollRestoration` option will be ignored for non-traversal navigations, i.e. those for which `e.navigationType !== "traverse"`. In such a case `restoreScroll()` will throw. - -- `restoreScroll()` will silently do nothing if called after the user has started scrolling the document. - -- `restoreScroll()` doesn't actually perform a single update of the scroll position. Rather, it puts the page in scroll-position-restoring mode. The scroll position could update several times as more elements load and [scroll anchoring](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor/Guide_to_scroll_anchoring) kicks in. +Some more details on how the navigation API handles scrolling with `"traverse"` and `"reload"` navigations: -- By default, any navigations which are intercepted with `e.intercept()` will _ignore_ the value of `history.scrollRestoration` from the classic history API. This allows developers to use `history.scrollRestoration` for controlling cross-document scroll restoration, while using the more-granular option to `intercept()` to control individual same-document navigations. +- `navigateEvent.scroll()` will silently do nothing if called after the user has started scrolling the document. -We could also add the following APIs in the future, but we are currently not planning on including them until we hear developer feedback that they'd be helpful: +- `navigateEvent.scroll()` doesn't actually perform a single update of the scroll position. Rather, it puts the page in scroll-position-restoring mode. The scroll position could update several times as more elements load and [scroll anchoring](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor/Guide_to_scroll_anchoring) kicks in. -- `scrollRestoration: "immediate"`: the browser performs its usual scroll restoration logic, but does so immediately instead of waiting for `promise`. -- `scrollRestoration: "auto"`: the browser performs its usual scroll restoration logic, at its usual indeterminate time. -- `const { x, y } = e.scrollDestination()` giving the current position the browser would restore to, if `e.restoreScroll()` was called. -- `e.restoreScroll({ onlyOnce: true })` to avoid scroll anchoring. +- By default, any navigations which are intercepted with `navigateEvent.intercept()` will _ignore_ the value of `history.scrollRestoration` from the classic history API. This allows developers to use `history.scrollRestoration` for controlling cross-document scroll restoration, while using the more-granular option to `intercept()` to control individual same-document navigations. ### Transitional time after navigation interception diff --git a/navigation_api.d.ts b/navigation_api.d.ts index ec8fdf7..1298318 100644 --- a/navigation_api.d.ts +++ b/navigation_api.d.ts @@ -113,6 +113,7 @@ declare class NavigateEvent extends Event { readonly info: unknown; intercept(options?: NavigationInterceptOptions): void; + scroll(): void; } interface NavigateEventInit extends EventInit { @@ -130,7 +131,7 @@ interface NavigateEventInit extends EventInit { interface NavigationInterceptOptions { handler?: () => Promise, focusReset?: "after-transition"|"manual", - scrollRestoration?: "after-transition"|"manual" + scroll?: "after-transition"|"manual" } declare class NavigationDestination { diff --git a/spec.bs b/spec.bs index 96c07e1..ea8d3bd 100644 --- a/spec.bs +++ b/spec.bs @@ -72,6 +72,7 @@ spec: html; urlPrefix: https://whatpr.org/html/6315/ text: snapshot source snapshot params; url: history.html#snapshotting-target-snapshot-params text: traverse the history by a delta; url: browsers.html#traverse-the-history-by-a-delta text: top-level traversable; url: history.html#top-level-traversable + text: scroll to the fragment; url: browsing-the-web.html#scroll-to-the-fragment-identifier for: apply the history step text: checkForUserCancellation; url: history.html#apply-history-step-check text: unsafeNavigationStartTime; url: history.html#apply-history-step-start-time @@ -80,6 +81,8 @@ spec: html; urlPrefix: https://whatpr.org/html/6315/ for: URL and history update steps text: serializedData; url: history.html#uhus-serializeddata text: historyHandling; url: history.html#uhus-historyhandling + for: Document + text: indicated part; url: browsing-the-web.html#the-indicated-part-of-the-document for: navigable text: active window; url: browsers.html#nav-window text: active document; url: history.html#nav-document @@ -617,7 +620,7 @@ During any given navigation, the {{Navigation}} object needs to keep track of th Whether {{NavigateEvent/intercept()}} was called Until the [=session history=] is updated (inside that same task) - So that we can suppress the normal scroll restoration logic in favor of the chosen {{NavigationInterceptOptions/scrollRestoration}} option value. + So that we can suppress the normal scroll restoration logic in favor of the chosen {{NavigationInterceptOptions/scroll}} option value. Furthermore, we need to account for the fact that there might be multiple traversals queued up, e.g. via @@ -1092,7 +1095,7 @@ interface NavigateEvent : Event { readonly attribute any info; undefined intercept(optional NavigationInterceptOptions options = {}); - undefined restoreScroll(); + undefined scroll(); }; dictionary NavigateEventInit : EventInit { @@ -1110,7 +1113,7 @@ dictionary NavigateEventInit : EventInit { dictionary NavigationInterceptOptions { NavigationInterceptHandler handler; NavigationFocusReset focusReset; - NavigationScrollRestoration scrollRestoration; + NavigationScrollBehavior scroll; }; enum NavigationFocusReset { @@ -1118,7 +1121,7 @@ enum NavigationFocusReset { "manual" }; -enum NavigationScrollRestoration { +enum NavigationScrollBehavior { "after-transition", "manual" }; @@ -1193,7 +1196,7 @@ enum NavigationType {

An arbitrary JavaScript value passed via {{Window/navigation}} APIs that initiated this navigation, or null if the navigation was initiated by the user or via a non-{{Window/navigation}} API. -

event.{{NavigateEvent/intercept()|intercept}}({ {{NavigationInterceptOptions/handler}}, {{NavigationInterceptOptions/focusReset}}, {{NavigationInterceptOptions/scrollRestoration}} }) +
event.{{NavigateEvent/intercept()|intercept}}({ {{NavigationInterceptOptions/handler}}, {{NavigationInterceptOptions/focusReset}}, {{NavigationInterceptOptions/scroll}} })

Intercepts this navigation, preventing its normally handling and instead converting it into a same-document navigation to the destination URL. @@ -1201,16 +1204,18 @@ enum NavigationType {

By default, using this method will cause focus to reset when any handlers' returned promises settle. Focus will be reset to the first element with the <{html-global/autofocus}> attribute set, or the <{body}> element if the attribute isn't present. The {{NavigationInterceptOptions/focusReset}} option can be set to "{{NavigationFocusReset/manual}}" to avoid this behavior. -

By default, using this method for "{{NavigationType/traverse}}" navigations will cause the browser's scroll restoration logic to be delayed until any handlers' returned promises settle. The {{NavigationInterceptOptions/scrollRestoration}} option can be set to "{{NavigationScrollRestoration/manual}}" to turn off scroll restoration entirely for this navigation, or control the timing of it by later calling {{NavigateEvent/restoreScroll()}}. +

By default, using this method will delay the browser's scroll restoration logic for "{{NavigationType/traverse}}" or "{{NavigationType/reload}}" navigations, or its scroll-reset/scroll-to-a-fragment logic for "{{NavigationType/push}}" and "{{NavigationType/replace}}" navigations, until any handlers' returned promises settle. The {{NavigationInterceptOptions/scroll}} option can be set to "{{NavigationScrollBehavior/manual}}" to turn off any browser-driven scroll behavior entirely for this navigation, or control the timing of it by later calling {{NavigateEvent/scroll()}}.

This method will throw a "{{SecurityError}}" {{DOMException}} if {{NavigateEvent/canIntercept}} is false, or if {{Event/isTrusted}} is false. It will throw an "{{InvalidStateError}}" {{DOMException}} if not called synchronously, during event dispatch.

-
event.{{NavigateEvent/restoreScroll()|restoreScroll}}() +
event.{{NavigateEvent/scroll()|scroll}}()
-

For "{{NavigationType/traverse}}" navigations which have set {{NavigationInterceptOptions/scrollRestoration}}: "{{NavigationScrollRestoration/manual}}" as part of their {{NavigateEvent/intercept()}} call, restores the scroll position using the browser's usual scroll restoration logic. +

For "{{NavigationType/traverse}}" or "{{NavigationType/reload}}" navigations which have set {{NavigationInterceptOptions/scroll}}: "{{NavigationScrollBehavior/manual}}" as part of their {{NavigateEvent/intercept()}} call, restores the scroll position using the browser's usual scroll restoration logic. -

If used on a non-"{{NavigationType/traverse}}" navigation, or on one which has not had {{NavigationInterceptOptions/scrollRestoration}} set appropriately, or if called more than once, this method will throw an "{{InvalidStateError}}" {{DOMException}}. +

For "{{NavigationType/push}}" or "{{NavigationType/replace}}" navigations which have set {{NavigationInterceptOptions/scroll}}: "{{NavigationScrollBehavior/manual}}" as part of their {{NavigateEvent/intercept()}} call, either resets the scroll position to the top of the document or scrolls to the fragment specified by {{NavigationDestination/url|event.destination.url}} if there is one. + +

If used on a navigation that has not had {{NavigationInterceptOptions/scroll}} set appropriately, or if called more than once, this method will throw an "{{InvalidStateError}}" {{DOMException}}.

@@ -1220,9 +1225,9 @@ A {{NavigateEvent}} has a classic history API serialize A {{NavigateEvent}} has a focus reset behavior, a {{NavigationFocusReset}}-or-null, initially null. -A {{NavigateEvent}} has a scroll restoration behavior, a {{NavigationScrollRestoration}}-or-null, initially null. +A {{NavigateEvent}} has a scroll behavior, a {{NavigationScrollBehavior}}-or-null, initially null. -A {{NavigateEvent}} has a did process scroll restoration, a boolean, initially false. +A {{NavigateEvent}} has a did process scroll behavior, a boolean, initially false. A {{NavigateEvent}} has a was intercepted, a boolean, initially false. @@ -1241,18 +1246,18 @@ A {{NavigateEvent}} has a navigation handler list 1. If |options|["{{NavigationInterceptOptions/focusReset}}"] [=map/exists=], then: 1. If [=this=]'s [=NavigateEvent/focus reset behavior=] is not null, and it is not equal to |options|["{{NavigationInterceptOptions/focusReset}}"], then the user agent may [=report a warning to the console=] indicating that the {{NavigationInterceptOptions/focusReset}} option for a previous call to {{NavigateEvent/intercept()}} was overridden by this new value, and the previous value will be ignored. 1. Set [=this=]'s [=NavigateEvent/focus reset behavior=] to |options|["{{NavigationInterceptOptions/focusReset}}"]. - 1. If |options|["{{NavigationInterceptOptions/scrollRestoration}}"] [=map/exists=], and [=this=]'s {{NavigateEvent/navigationType}} attribute was initialized to "{{NavigationType/traverse}}", then: - 1. If [=this=]'s [=NavigateEvent/scroll restoration behavior=] is not null, and it is not equal to |options|["{{NavigationInterceptOptions/scrollRestoration}}"], then the user agent may [=report a warning to the console=] indicating that the {{NavigationInterceptOptions/scrollRestoration}} option for a previous call to {{NavigateEvent/intercept()}} was overridden by this new value, and the previous value will be ignored. - 1. Set [=this=]'s [=NavigateEvent/scroll restoration behavior=] to |options|["{{NavigationInterceptOptions/scrollRestoration}}"]. + 1. If |options|["{{NavigationInterceptOptions/scroll}}"] [=map/exists=], then: + 1. If [=this=]'s [=NavigateEvent/scroll behavior=] is not null, and it is not equal to |options|["{{NavigationInterceptOptions/scroll}}"], then the user agent may [=report a warning to the console=] indicating that the {{NavigationInterceptOptions/scroll}} option for a previous call to {{NavigateEvent/intercept()}} was overridden by this new value, and the previous value will be ignored. + 1. Set [=this=]'s [=NavigateEvent/scroll behavior=] to |options|["{{NavigationInterceptOptions/scroll}}"].
- The restoreScroll() method steps are: + The scroll() method steps are: - 1. If [=this=]'s {{NavigateEvent/navigationType}} was not initialized to "{{NavigationType/traverse}}", then throw an "{{InvalidStateError}}" {{DOMException}}. - 1. If [=this=]'s [=NavigateEvent/scroll restoration behavior=] is not "{{NavigationScrollRestoration/manual}}", then throw an "{{InvalidStateError}}" {{DOMException}}. - 1. If [=this=]'s [=NavigateEvent/did process scroll restoration=] is true, then throw an "{{InvalidStateError}}" {{DOMException}}. - 1. [=Restore scroll position data=] given [=this=]'s [=relevant global object=]'s [=Window/navigable=]'s [=navigable/active session history entry=]. + 1. If [=this=]'s [=NavigateEvent/scroll behavior=] is not "{{NavigationScrollBehavior/manual}}", then throw an "{{InvalidStateError}}" {{DOMException}}. + 1. If [=this=]'s [=NavigateEvent/did process scroll behavior=] is true, then throw an "{{InvalidStateError}}" {{DOMException}}. + 1. If [=this=]'s [=relevant global object=]'s [=associated Document=] is not [=Document/fully active=], then throw an "{{InvalidStateError}}" {{DOMException}}. + 1. [=Definitely process scroll behavior=] given [=this=].
@@ -1440,7 +1445,7 @@ The sameDocument getter steps a 1. [=Mark as handled=] |navigation|'s [=Navigation/transition=]'s [=NavigationTransition/finished promise=].

See the discussion about other finished promises as to why this is done.

1. If |navigationType| is "{{NavigationType/traverse}}", then set |navigation|'s [=Navigation/suppress normal scroll restoration during ongoing navigation=] to true. -

If |event|'s [=NavigateEvent/scroll restoration behavior=] was set to "{{NavigationScrollRestoration/after-transition}}", then we will [=potentially perform scroll restoration=] below. Otherwise, there will be no scroll restoration. That is, no navigation which is intercepted by {{NavigateEvent/intercept()}} goes through the normal scroll restoration process; scroll restoration for such navigations is either done manually, by the web developer, or is done after the transition. +

If |event|'s [=NavigateEvent/scroll behavior=] was set to "{{NavigationScrollBehavior/after-transition}}", then we will [=potentially process scroll behavior|potentially perform scroll restoration=] below. Otherwise, there will be no scroll restoration. That is, no navigation which is intercepted by {{NavigateEvent/intercept()}} goes through the normal scroll restoration process; scroll restoration for such navigations is either done manually, by the web developer, or is done after the transition. 1. If |navigationType| is "{{NavigationType/push}}" or "{{NavigationType/replace}}", then run the [=URL and history update steps=] given |document| and |event|'s {{NavigateEvent/destination}}'s [=NavigationDestination/URL=], with [=URL and history update steps/serializedData=] set to |event|'s [=NavigateEvent/classic history API serialized data=] and [=URL and history update steps/historyHandling=] set to |navigationType|.

If |navigationType| is "{{NavigationType/reload}}", then we are converting a reload into a "same-document reload", for which the URL and history update steps are not appropriate. Navigation API-related stuff still happens, such as updating the [=session history/current entry=]'s [=session history entry/navigation API state=] if this was caused by a call to {{Navigation/reload()|navigation.reload()}}, and all the ongoing navigation tracking. @@ -1457,7 +1462,7 @@ The sameDocument getter steps a 1. Set |navigation|'s [=Navigation/transition=] to null. 1. If |ongoingNavigation| is non-null, then [=navigation API method navigation/resolve the finished promise=] for |ongoingNavigation|. 1. [=Potentially reset the focus=] given |navigation| and |event|. - 1. [=Potentially perform scroll restoration=] given |navigation| and |event|. + 1. [=Potentially process scroll behavior=] given |event|. and the following failure steps given reason |rejectionReason|: 1. If |event|'s {{NavigateEvent/signal}} is [=AbortSignal/aborted=], then abort these steps. 1. [=Fire an event=] named {{Navigation/navigateerror}} at |navigation| using {{ErrorEvent}}, with {{ErrorEvent/error}} initialized to |rejectionReason|, and {{ErrorEvent/message}}, {{ErrorEvent/filename}}, {{ErrorEvent/lineno}}, and {{ErrorEvent/colno}} initialized to appropriate values that can be extracted from |rejectionReason| in the same underspecified way the user agent typically does for the report an exception algorithm. @@ -1465,7 +1470,7 @@ The sameDocument getter steps a 1. Set |navigation|'s [=Navigation/transition=] to null. 1. If |ongoingNavigation| is non-null, then [=navigation API method navigation/reject the finished promise=] for |ongoingNavigation| with |rejectionReason|. 1. [=Potentially reset the focus=] given |navigation| and |event|. -

Although we still [=potentially reset the focus=] for such failed transitions, we do not [=potentially perform scroll restoration=] for them. +

Although we still [=potentially reset the focus=] for such failed transitions, we do not [=potentially process scroll behavior=] for them. 1. Otherwise, if |ongoingNavigation| is non-null, then [=navigation API method navigation/clean up=] |ongoingNavigation|. 1. If |event|'s [=NavigateEvent/was intercepted=] is true and |navigationType| is "{{NavigationType/push}}", "{{NavigationType/replace}}", or "{{NavigationType/reload}}", then return false. 1. Return true. @@ -1526,15 +1531,25 @@ The sameDocument getter steps a

- To potentially perform scroll restoration given a {{Navigation}} object |navigation| and an {{NavigateEvent}} |event|: + To potentially process scroll behavior given a {{NavigateEvent}} |event|: 1. If |event|'s [=NavigateEvent/was intercepted=] is false, then return. - 1. If |event|'s {{NavigateEvent/navigationType}} was not initialized to "{{NavigationType/traverse}}", then return. - 1. If |event|'s [=NavigateEvent/scroll restoration behavior=] is "{{NavigationScrollRestoration/manual}}", then return. -

If it was left as null, then we treat that as "{{NavigationScrollRestoration/after-transition}}", and continue onward. - 1. If |event|'s [=NavigateEvent/did process scroll restoration=] is true, then return. - 1. Set |event|'s [=NavigateEvent/did process scroll restoration=] to true. - 1. [=Restore scroll position data=] given |navigation|'s [=Navigation/current entry=]'s [=NavigationHistoryEntry/session history entry=]. + 1. If |event|'s [=NavigateEvent/scroll behavior=] is "{{NavigationScrollBehavior/manual}}", then return. +

If it was left as null, then we treat that as "{{NavigationScrollBehavior/after-transition}}", and continue onward. + 1. If |event|'s [=NavigateEvent/did process scroll behavior=] is true, then return. + 1. Set |event|'s [=NavigateEvent/did process scroll behavior=] to true. + 1. [=Definitely process scroll behavior=] given |event|. +

+ +
+ To definitely process scroll behavior given a {{Navigation}} object |navigation| and an {{NavigateEvent}} |event|: + + 1. If |event|'s {{NavigateEvent/navigationType}} was initialized to "{{NavigationType/traverse}}" or "{{NavigationType/reload}}", then [=restore scroll position data=] given |event|'s [=relevant global object=]'s [=Window/navigable=]'s [=navigable/active session history entry=]. + 1. Otherwise, [=scroll to the fragment=] given |navigation|'s [=relevant global object=]'s [=associated Document=]. + 1. Otherwise, + 1. Let |document| be |event|'s [=relevant global object=]'s [=associated Document=]. + 1. If |document|'s [=Document/indicated part=] is null, then [=scroll to the beginning of the document=] given |document|. + 1. Otherwise, [=scroll to the fragment=] given |document|.
@@ -1958,7 +1973,7 @@ Update the focus fixup rule to additionally set the {{Documen

Scroll restoration

-To support the {{NavigationInterceptOptions/scrollRestoration}} option, as well as to fix whatwg/html#7517, the following patches need to be made: +To support the {{NavigationInterceptOptions/scroll}} option, as well as to fix whatwg/html#7517, the following patches need to be made: Add a boolean, has been scrolled by the user, initially false, to {{Document}} objects. State that if the user scrolls the document, the user agent must set that document's [=Document/has been scrolled by the user=] to true. Modify the unload a document algorithm to set this back to false. @@ -1981,7 +1996,7 @@ Add a boolean, has been scrolled by the user, initiall In addition to the existing note, add the following one: -

If the [=Navigation/suppress normal scroll restoration during ongoing navigation=] boolean is true, then [=restoring scroll position data=] might still happen at a later point, as part of [=potentially perform scroll restoration|potentially performing scroll restoration=] for the relevant {{Navigation}} object, or via a {{NavigateEvent/restoreScroll()|navigateEvent.restoreScroll()}} method call. +

If the [=Navigation/suppress normal scroll restoration during ongoing navigation=] boolean is true, then [=restoring scroll position data=] might still happen at a later point, as part of [=potentially process scroll behavior|potentially processing scroll behavior=] for the relevant {{Navigation}} object, or via a {{NavigateEvent/scroll()|navigateEvent.scroll()}} method call.

Canceling navigation and traversals

From 8e72dada0de90109e90cff5bdc5f34e848d52ad9 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 29 Jun 2022 17:52:43 +0900 Subject: [PATCH 2/4] Explainer updates per Jake's review --- README.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c1c3e0c..d5a917f 100644 --- a/README.md +++ b/README.md @@ -614,7 +614,7 @@ We can also extend the `focusReset` option with other behaviors in the future. H #### Scrolling to fragments and scroll resetting -Current single-page app navigations leave the user's scroll position where it is. This is true even if you try to navigate to a fragment, e.g. by doing `history.pushState("/article#subheading")`. The latter has caused significant pain in client-side router libraries; see e.g. [remix-run/react-router#394](https://github.com/remix-run/react-router/issues/394), or the manual code that is needed to handle this case in [Vue](https://sourcegraph.com/github.com/vuejs/router/-/blob/src/scrollBehavior.ts?L81-140), [Angular](https://github.com/angular/angular/blob/main/packages/router/src/router_scroller.ts#L76-L77), [React Router Hash Link](https://github.com/rafgraph/react-router-hash-link/blob/main/src/HashLink.jsx), and others. +When you change the URL with `history.pushState()`/`history.replaceState()`, the user's scroll position stays where it is. This is true even if you try to navigate to a fragment, e.g. by doing `history.pushState("/article#subheading")`. The latter has caused significant pain in client-side router libraries; see e.g. [remix-run/react-router#394](https://github.com/remix-run/react-router/issues/394), or the manual code that is needed to handle this case in [Vue](https://sourcegraph.com/github.com/vuejs/router/-/blob/src/scrollBehavior.ts?L81-140), [Angular](https://github.com/angular/angular/blob/main/packages/router/src/router_scroller.ts#L76-L77), [React Router Hash Link](https://github.com/rafgraph/react-router-hash-link/blob/main/src/HashLink.jsx), and others. With the navigation API, there is a different default behavior, controllable via another option to `navigateEvent.intercept()`: @@ -624,18 +624,23 @@ With the navigation API, there is a different default behavior, controllable via The `navigateEvent.scroll()` method could be useful if you know you have loaded the element referenced by the hash, or if you know you want to reset the scroll position to the top of the document early, before the full transition has finished. For example: ```js -if (navigateEvent.navigationType === "push" || navigateEvent.navigationType === "replace") { - navigateEvent.intercept({ - scroll: "manual", - async handler() { - await fetchDataAndSetUpDOM(navigateEvent.url); - navigateEvent.scroll(); +const freshEntry = + navigateEvent.navigationType === "push" || + navigateEvent.navigationType === "replace"; + +navigateEvent.intercept({ + scroll: freshEntry ? "manual" : "after-transition", + async handler() { + await fetchDataAndSetUpDOM(navigateEvent.url); - // Note: navigateEvent.scroll() will update what :target points to. - await fadeInTheScrolledToElement(document.querySelector(":target")); + if (freshEntry) { + navigateEvent.scroll(); } - }); -} + + // Note: navigateEvent.scroll() will update what :target points to. + await fadeInTheScrolledToElement(document.querySelector(":target")); + }, +}); ``` If you want to only perform the scroll-to-a-fragment behavior, and not reset the scroll position to the top if there is no matching fragment, then you can use `"manual"` combined with only calling `navigateEvent.scroll()` when `(new URL(navigateEvent.destination.url)).hash` points to an element that exists. @@ -660,7 +665,7 @@ For `"traverse"` and `"reload"`, the `navigateEvent.scroll()` API performs the b ```js if (navigateEvent.navigationType === "traverse" || navigateEvent.navigationType === "reload") { navigateEvent.intercept({ - scroll: "manual" + scroll: "manual", async handler() { await fetchDataAndSetUpDOM(); navigateEvent.scroll(); From 7db0922e8e0c1420e3ed26ccb3abb01e10640112 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 29 Jun 2022 18:01:47 +0900 Subject: [PATCH 3/4] Allow calling scroll() even when using "after-transition" --- spec.bs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/spec.bs b/spec.bs index ea8d3bd..be4f8de 100644 --- a/spec.bs +++ b/spec.bs @@ -1204,18 +1204,18 @@ enum NavigationType {

By default, using this method will cause focus to reset when any handlers' returned promises settle. Focus will be reset to the first element with the <{html-global/autofocus}> attribute set, or the <{body}> element if the attribute isn't present. The {{NavigationInterceptOptions/focusReset}} option can be set to "{{NavigationFocusReset/manual}}" to avoid this behavior. -

By default, using this method will delay the browser's scroll restoration logic for "{{NavigationType/traverse}}" or "{{NavigationType/reload}}" navigations, or its scroll-reset/scroll-to-a-fragment logic for "{{NavigationType/push}}" and "{{NavigationType/replace}}" navigations, until any handlers' returned promises settle. The {{NavigationInterceptOptions/scroll}} option can be set to "{{NavigationScrollBehavior/manual}}" to turn off any browser-driven scroll behavior entirely for this navigation, or control the timing of it by later calling {{NavigateEvent/scroll()}}. +

By default, using this method will delay the browser's scroll restoration logic for "{{NavigationType/traverse}}" or "{{NavigationType/reload}}" navigations, or its scroll-reset/scroll-to-a-fragment logic for "{{NavigationType/push}}" and "{{NavigationType/replace}}" navigations, until any handlers' returned promises settle. The {{NavigationInterceptOptions/scroll}} option can be set to "{{NavigationScrollBehavior/manual}}" to turn off any browser-driven scroll behavior entirely for this navigation, or {{NavigateEvent/scroll()|event.scroll()}} can be called before the promise settles to trigger this behavior early.

This method will throw a "{{SecurityError}}" {{DOMException}} if {{NavigateEvent/canIntercept}} is false, or if {{Event/isTrusted}} is false. It will throw an "{{InvalidStateError}}" {{DOMException}} if not called synchronously, during event dispatch.

event.{{NavigateEvent/scroll()|scroll}}()
-

For "{{NavigationType/traverse}}" or "{{NavigationType/reload}}" navigations which have set {{NavigationInterceptOptions/scroll}}: "{{NavigationScrollBehavior/manual}}" as part of their {{NavigateEvent/intercept()}} call, restores the scroll position using the browser's usual scroll restoration logic. +

For "{{NavigationType/traverse}}" or "{{NavigationType/reload}}" navigations, restores the scroll position using the browser's usual scroll restoration logic. -

For "{{NavigationType/push}}" or "{{NavigationType/replace}}" navigations which have set {{NavigationInterceptOptions/scroll}}: "{{NavigationScrollBehavior/manual}}" as part of their {{NavigateEvent/intercept()}} call, either resets the scroll position to the top of the document or scrolls to the fragment specified by {{NavigationDestination/url|event.destination.url}} if there is one. +

For "{{NavigationType/push}}" or "{{NavigationType/replace}}" navigations, either resets the scroll position to the top of the document or scrolls to the fragment specified by {{NavigationDestination/url|event.destination.url}} if there is one. -

If used on a navigation that has not had {{NavigationInterceptOptions/scroll}} set appropriately, or if called more than once, this method will throw an "{{InvalidStateError}}" {{DOMException}}. +

If called more than once, or called after automatic post-transition scroll processing has happened due to the {{NavigationInterceptOptions/scroll}} option being left as "{{NavigationScrollBehavior/after-transition}}", this method will throw an "{{InvalidStateError}}" {{DOMException}}.

@@ -1254,7 +1254,6 @@ A {{NavigateEvent}} has a navigation handler list
The scroll() method steps are: - 1. If [=this=]'s [=NavigateEvent/scroll behavior=] is not "{{NavigationScrollBehavior/manual}}", then throw an "{{InvalidStateError}}" {{DOMException}}. 1. If [=this=]'s [=NavigateEvent/did process scroll behavior=] is true, then throw an "{{InvalidStateError}}" {{DOMException}}. 1. If [=this=]'s [=relevant global object=]'s [=associated Document=] is not [=Document/fully active=], then throw an "{{InvalidStateError}}" {{DOMException}}. 1. [=Definitely process scroll behavior=] given [=this=]. @@ -1537,15 +1536,15 @@ The sameDocument getter steps a 1. If |event|'s [=NavigateEvent/scroll behavior=] is "{{NavigationScrollBehavior/manual}}", then return.

If it was left as null, then we treat that as "{{NavigationScrollBehavior/after-transition}}", and continue onward. 1. If |event|'s [=NavigateEvent/did process scroll behavior=] is true, then return. - 1. Set |event|'s [=NavigateEvent/did process scroll behavior=] to true. 1. [=Definitely process scroll behavior=] given |event|.

- To definitely process scroll behavior given a {{Navigation}} object |navigation| and an {{NavigateEvent}} |event|: + To definitely process scroll behavior given a {{NavigateEvent}} |event|: + 1. Set |event|'s [=NavigateEvent/did process scroll behavior=] to true. 1. If |event|'s {{NavigateEvent/navigationType}} was initialized to "{{NavigationType/traverse}}" or "{{NavigationType/reload}}", then [=restore scroll position data=] given |event|'s [=relevant global object=]'s [=Window/navigable=]'s [=navigable/active session history entry=]. - 1. Otherwise, [=scroll to the fragment=] given |navigation|'s [=relevant global object=]'s [=associated Document=]. + 1. Otherwise, [=scroll to the fragment=] given |event|'s [=relevant global object=]'s [=associated Document=]. 1. Otherwise, 1. Let |document| be |event|'s [=relevant global object=]'s [=associated Document=]. 1. If |document|'s [=Document/indicated part=] is null, then [=scroll to the beginning of the document=] given |document|. From 2da12c19a9e0189dba20e9912c82e63b85ef1dcb Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 13 Jul 2022 12:52:31 +0900 Subject: [PATCH 4/4] Fix per review comments --- README.md | 4 ++-- spec.bs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d5a917f..64322fc 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,8 @@ We can also extend the `focusReset` option with other behaviors in the future. H #### Scrolling to fragments and scroll resetting +_Note that the discussion in this section applies only to `"push"` and `"replace"` navigations. For the behavior for `"traverse"` and `"reload"` navigations, see the [next section](#scroll-position-restoration)._ + When you change the URL with `history.pushState()`/`history.replaceState()`, the user's scroll position stays where it is. This is true even if you try to navigate to a fragment, e.g. by doing `history.pushState("/article#subheading")`. The latter has caused significant pain in client-side router libraries; see e.g. [remix-run/react-router#394](https://github.com/remix-run/react-router/issues/394), or the manual code that is needed to handle this case in [Vue](https://sourcegraph.com/github.com/vuejs/router/-/blob/src/scrollBehavior.ts?L81-140), [Angular](https://github.com/angular/angular/blob/main/packages/router/src/router_scroller.ts#L76-L77), [React Router Hash Link](https://github.com/rafgraph/react-router-hash-link/blob/main/src/HashLink.jsx), and others. With the navigation API, there is a different default behavior, controllable via another option to `navigateEvent.intercept()`: @@ -645,8 +647,6 @@ navigateEvent.intercept({ If you want to only perform the scroll-to-a-fragment behavior, and not reset the scroll position to the top if there is no matching fragment, then you can use `"manual"` combined with only calling `navigateEvent.scroll()` when `(new URL(navigateEvent.destination.url)).hash` points to an element that exists. -Note that the discussion in this section applies only to `"push"` and `"replace"` navigations. For the behavior for `"traverse"` and `"reload"` navigations, read on... - #### Scroll position restoration A common pain point for web developers is scroll restoration during `"traverse"` and `"reload"` navigations. The essential problem is that scroll restoration happens unpredictably, and often at the wrong times. For example: diff --git a/spec.bs b/spec.bs index be4f8de..e65352e 100644 --- a/spec.bs +++ b/spec.bs @@ -1255,6 +1255,7 @@ A {{NavigateEvent}} has a navigation handler list The scroll() method steps are: 1. If [=this=]'s [=NavigateEvent/did process scroll behavior=] is true, then throw an "{{InvalidStateError}}" {{DOMException}}. + 1. If [=this=]'s [=NavigateEvent/was intercepted=] is false, then throw an "{{InvalidStateError}}" {{DOMException}}. 1. If [=this=]'s [=relevant global object=]'s [=associated Document=] is not [=Document/fully active=], then throw an "{{InvalidStateError}}" {{DOMException}}. 1. [=Definitely process scroll behavior=] given [=this=].
@@ -1544,7 +1545,6 @@ The sameDocument getter steps a 1. Set |event|'s [=NavigateEvent/did process scroll behavior=] to true. 1. If |event|'s {{NavigateEvent/navigationType}} was initialized to "{{NavigationType/traverse}}" or "{{NavigationType/reload}}", then [=restore scroll position data=] given |event|'s [=relevant global object=]'s [=Window/navigable=]'s [=navigable/active session history entry=]. - 1. Otherwise, [=scroll to the fragment=] given |event|'s [=relevant global object=]'s [=associated Document=]. 1. Otherwise, 1. Let |document| be |event|'s [=relevant global object=]'s [=associated Document=]. 1. If |document|'s [=Document/indicated part=] is null, then [=scroll to the beginning of the document=] given |document|.