transitionWhile doesn't quite work for scroll restoration
jakearchibald opened this issue · 17 comments
A regular navigation happens roughly like this:
- Begin fetching
- Time passes
- Fetch complete
- Capture & store scroll position
- Switch to new page
- Restore scrolling if applicable
transitionWhile
doesn't really fit in this model.
If an unsettled promise is passed to transitionWhile
(due to pending network activity), it goes like this:
- Begin fetching
transitionWhile
called - capture & store scroll position- Time passes
- Fetch complete
- Alter DOM
- Promise passed to
transitionWhile
resolves - Restore scrolling if applicable
This isn't ideal, as the user may have scrolled during the fetch, but that change is discarded.
If a fulfilled promise is passed to transitionWhile
(due to the page data being available synchronously), it goes like this:
- Alter DOM
transitionWhile
called - capture & store scroll position- Restore scrolling if applicable
This behaviour is broken, as the new DOM may drastically alter the scroll position. Also, the developer may choose to reset the scroll position after altering the DOM, if the page is conceptually new.
It feels like the API needs to change so it knows when to capture the scroll position.
An alternative design:
navigation.addEventListener('navigate', (event) => {
event.transitionWhile(async (transition) => {
const response = await fetch(dataURL);
const data = await response.json();
transition.captureState();
await updateDOM(data);
});
});
Here, transitionWhile
takes a callable. It captures scroll and focus state before calling the callable. This solves the synchronous issue.
The developer can call captureState
to recapture state at a later time, ideally just before updating the DOM.
Scroll positions are restored when the promise returned by the callable fulfills.
This is similar to the issue that lead me to defer url changes until (the equivalent of) transition completion in our current use of History. When the URL updates on the leading edge, every relative link in the DOM becomes a minesweeper cell for the duration of the transition. Usually too quick to matter, but not guaranteed to be, and especially a hazard for the click-everything-at-least-twice-but-not-quite-fast-enough-for-double-click senior cohort (RIP, grandpa!).
Ohh, I think that's different enough to be its own issue #232
This isn't ideal, as the user may have scrolled during the fetch, but that change is discarded.
This feels like a general symptom of #232. Basically, the current API assumes that everything transitions to the new history entry the moment you call transitionWhile()
. In practice this can be an issue for some apps. Apps that are non-interactive after transitionWhile()
are well-positioned, but apps that allow interaction should probably delay the URL update/state capture/history entry change until they stop allowing interaction. (Which might be, until they have enough data for the next page.) And that needs a new API, per #232.
If a fulfilled promise is passed to
transitionWhile
(due to the page data being available synchronously), it goes like this:
I don't think this is correct. The following code should be used for sync renders:
navigation.addEventListener("navigate", event => {
event.transitionWhile(Promise.resolve());
updateDOM(data);
});
This changes the URL, and captures the state, before updating the DOM.
Whereas, if there's an immediately-fulfilled promise involved, then the usual flow of
navigation.addEventListener("navigate", event => {
event.transitionWhile(async () => {
await doRouting();
await updateDOM();
});
});
where doRouting()
returns an immediately-fulfilled promise, becomes
- Begin routing
- transitionWhile called - capture & store scroll position
- Alter DOM (possibly async)
- Promise passed to transitionWhile resolves
- Restore scrolling if applicable
An alternative design:
If possible I'd like to avoid decoupling state capture and changing the history entry, since those are pretty coupled in current implementations and spec architecture.
Some stuff from internal discussion:
Having to change how you call this API whether it's a sync navigation or not seems like a gotcha, and restricts how you can write your routing logic. Having to add a seemingly redundant await 0
or whatever seems hacky too (and doesn't appear to work in the current implementation https://static-misc-3.glitch.me/nav-api/scroll-restore-sync/).
If transitionWhile
takes a callback, that at least guarantees scroll position can be captured before the DOM change.
apps that allow interaction should probably delay the URL update/state capture/history entry change until they stop allowing interaction
Allowing interactivity should be the norm, as it is with MPA. Otherwise we're getting into sync XHR territory 😄
I’d never considered the option of disabling interactivity during a transition at all admittedly. Is that a common practice currently? My first thought is that it seems like it could add a kinda “user-hostile” risk to situations where things don’t go as planned — like, if there’s a bug or network issue leading to a very long transition, I’d expect to be able to continue having the option to click another link personally and if this interaction produced no response, it could give the impression that the app itself had “frozen”. Is this what you mean by sync XHR territory @jakearchibald?
Exactly. Sync XHR blocks JS, therefore blocks all interactivity until the fetch completes.
If we move to a model where a callable is passed to transitionWhile
, it might be better to move the callback to the option object.
event.transitionWhile({
focusReset: 'manual',
async handler() {
// …
},
});
This means that options that impact the behaviour of the callback can appear before the callback in the source.
(it's a pet hate of mine that things like setTimeout
and addEventListener
end up having important details lots-of-scrolling away)
move to a model where a callable is passed
@jakearchibald iiuc that’s already the (intended) model (from an ES POV) because of the intention to define new Promise<T>
conversion steps in Web IDL that would call callable input, something like Promise.resolve(IsCallable(n) ? n() : n)
:
So what's going on here is a bit subtle which makes the fix a bit subtle too.
Basically the scroll position is captured when the navigate
event is totally finished firing. This is not the same as:
- When the first line of code in an IIAFE runs, using the
event.transitionWhile((async () => { ... })())
pattern. - When
transitionWhile()
itself is called.
This is most obviously seen in the case of multiple navigate
events. But also, in the case of user-initiated navigations, microtasks will run first, before control returns to the part of the spec that performs the URL and history update steps and thus captures the current scroll position.
So indeed, to work around the current situation you need to find a way to delay your DOM-update code until after navigate
and any associated microtasks are all done firing. await Promise.resolve()
will not suffice (for user-initiated navigations); you need await promiseDelay(0)
or similar. Ick.
This also means that the initial ideas floated here, of having transitionWhile()
take a handler function which it runs, doesn't necessarily work.
- If we run that handler immediately, like is proposed in whatwg/webidl#1020, we just recreate the same problem. It's too easy to put sync code into the handler that mutates the DOM, which will run before state is captured.
- If we run the handler "as part of
transitionWhile()
after it does any interesting work", which is I think what @jakearchibald was gesturing at, this doesn't actually do anything. Because the interesting work done bytransitionWhile()
doesn't include capturing scroll position etc. It just stores some stuff for later processing after thenavigate
event is completely done.
But, if we have it take a handler which only runs after navigate
has finished firing and the URL and history update steps have taken place, this works. It also seems nicer in a number of ways:
- If some other
navigate
listener callse.preventDefault()
, your handler will never run. You don't even need to worry aboutAbortSignal
s. - This totally avoids the sync/async split in the IIAFE pattern, where the sync part runs before commit and the async part runs after. Which has caused other problems, e.g. #124 (comment).
I'm unsure on the exact migration path here. It still feels like "transition while this promise is ongoing" is reasonable, but I admit I haven't seen it too much in the demos we've built, and maybe it's just a footgun by encouraging you to run promise-producing code before commit.
The options I can think of are:
- Introduce an overload between promises and functions, where the function version behaves rather differently.
- This is not forward-compatible with whatwg/webidl#1020, if we do that.
- This would require custom IDL bindings in spec and implementation since promises can't be overloaded usually.
- Introduce an overload between (promise, dictionary) and (dictionary-with-handler)
- This would similarly require custom IDL bindings
- Same as (1), but also phase out the promise overload over time.
- Same as (2), but also phase out the (promise, dictionary) overload over time.
- Introduce a new method that takes a dictionary
- This also might allow us to avoid the common-in-transitional-code pattern of
event.transitionWhile(Promise.resolve())
. You'd just callevent.newMethod()
and it'd convert the navigation. - The hard part here is figuring out the new name. We previously bikeshedded in #94 (comment) for some time.
- This also might allow us to avoid the common-in-transitional-code pattern of
- Same as (5), but also phase out
transitionWhile()
(I don't think we'd particularly want to introduce a new method that takes a (function, dictionary) since as @jakearchibald mentions it's a bit annoying.)
Thoughts welcome on which path would be best.
4 seems fine. I like the idea of 6 because I'm still worried that 'transition' is a naming clash with animated transitions. The only naming suggestion I have is intercept
.
Should we allow
event.intercept(async () => {
// ...
});
as an overload? Or just
event.intercept({
async handler() {
// ...
}
});
? The overload makes a lot of short code snippets in the README and tests look nicer, but I'm not sure that it would matter in the real world...
I guess
event.intercept({ async handler() {
// ...
} });
isn't so bad.
Accidentally merged what was supposed to be a PR to main; reopening.
New question: should we rename navigation.transition
to navigation.ongoing
or navigation.ongoingIntercept
or something? That would solve WICG/view-transitions#143 entirely.
If we did that we'd probably also have to rename the two "after-transition"
enum values. I don't really like that. I still feel like we have a good claim to the name "transition" here, even though we're not a CSS transition.
I still don't like transition as a name, but I understand if you don't want to change it.
I could make a Twitter poll and see how devs feel about either naming?
The overload seems ok.
Playing around with the Navigation API and I found myself writing intercept
in a few places, prior to seeing this discussion.
I must admit I also share the same concerns as Jake regarding clashes/possible confusions with the shared element transition API. But I also don't think "transition" reflects as well as "intercept" what the method does.
Sample code
export default class Router {
constructor() {
this.onNavigationAttempt = this.onNavigationAttempt.bind(this);
window.navigation.addEventListener('navigate', this.onNavigationAttempt);
}
public shouldInterceptNavigation(e: NavigateEvent): boolean {
return e.canTransition && !e.hashChange;
}
protected onNavigationAttempt(e: NavigateEvent): void {
if (!this.shouldInterceptNavigation(e)) {
return;
}
this.interceptNavigation(e);
}
protected interceptNavigation(e: NavigateEvent): void {
e.transitionWhile(
this.asyncStuff(e).then(() => {
this.onNavigationIntercepted(e);
})
);
}
protected onNavigationIntercepted(e: NavigateEvent): void {
//
}
}