Open mattgperry opened 1 year ago
Can you create a representative demo in React that shows this issue? Include where you would perform the synchronous snapshot if you could.
This would show the problem, the size of the problem, and the impact of the proposed solution.
If I understand the problem, is that between the event and the next render opportunity the framework has a gap of time where it could potentially run some operations to prepare for the next state.
The problem with the proposed solution is that snapshotting in itself would become an expensive synchronous operation, so it would delay updating the state and this won't add much value...
Also the frameworks are often not the only pieces of code that update the DOM on the page, there are 3p libraries, extensions etc. A synchronous DOM render is a radical solution to a problem that can be solved in other ways.
The solution we should strive towards is the alternative, something like
framework.startViewTransition = () => {
const donePreparing = framework.prepareState();
document.startViewTransition(() => {
await donePreparing;
await framework.commitToDOM()
});
}
And yes, frameworks might have to adjust to this, and potentially do something like this internally - likely on the router level, if they want this optimization of "let's start preparing before the render opportunity". I'm not sure that producing a sync snapshot mechanism would magically solve this for multiple frameworks.
React's snapshotting callback only works for class components right? They seem pretty discouraged vs function components + hooks.
So, even if this was a thing, we're still asking for changes in frameworks, which is "unlikely"?
It’s still the canonical way to snapshot in React
On Sat, 23 Sep 2023 at 11:30, Jake Archibald @.***> wrote:
React's snapshotting callback only works for class components right? They seem pretty discouraged vs function components + hooks.
So, even if this was a thing, we're still asking for changes in frameworks, which is "unlikely"?
— Reply to this email directly, view it on GitHub https://github.com/w3c/csswg-drafts/issues/9400#issuecomment-1732265916, or unsubscribe https://github.com/notifications/unsubscribe-auth/AB34WKXOY6KKEXAJO7WP733X32T33ANCNFSM6AAAAAA5EBUSCU . You are receiving this because you authored the thread.Message ID: @.***>
The problem with the proposed solution is that snapshotting in itself would become an expensive synchronous operation, so it would delay updating the state and this won't add much value...
This is true in instances where snapshotting is an expensive operation but compared to render times on an expensive page, it isn't. Like what kind of durations are you seeing or expecting for snapshotting? Because multi-second renders isn't unthinkable and that's work that outside of startViewTransition
and/or flushSync
is interruptable.
The problem with the proposed solution is that snapshotting in itself would become an expensive synchronous operation, so it would delay updating the state and this won't add much value...
This is true in instances where snapshotting is an expensive operation but compared to render times on an expensive page, it isn't. Like what kind of durations are you seeing or expecting for snapshotting? Because multi-second renders isn't unthinkable and that's work that outside of
startViewTransition
and/orflushSync
is interruptable.
The time gap we're talking about between startViewTransition
and its callback is usually <16ms, same as calling requestAnimationFrame
. changing anything about it is not going to make a dent in a multi-second render.
flushSync
is indeed uninterruptible, but the startViewTransition
callback is promise-based, and the use-view-transition
hook is interruptible as it doesn't rely on flushSync
.
So I think I might have misunderstood what problem you're trying to solve here that's not already solved by use-view-transitions
.
Think about it this way. Let's say a state-swap takes 2s (interruptible), out of which rendering (style, layout, paint, rAF callbacks etc) take 10ms.
With flushSync
:
document.startViewTransition(() => {
// after 16ms or less
flushSync(() => {
// This is 2s work, becoming blocking due to flushSync.
updateState();
});
});
// Total blocking: 2016ms
With async rendering (what use-view-transitions
does):
document.startViewTransition(() => {
// after 16ms or less
updateState();
waitForSomeReadyState();
});
});
// Total blocking: <16ms
With sync snapshotting:
/* This needs to render, so 10ms */
document.startViewTransitionSync();
/* Non-blocking */
updateState();
document.notifyUpdateComplete();
// Total blocking: 10ms */
With preparing work in parallel to waiting for frame:
startPreparing();
document.startViewTransition(() => {
// after 16ms or less
updateState();
waitForSomeReadyState();
});
});
// Total blocking: maybe 0??
So considering that you use something like use-view-transitions
and not flushSync
, we're talking about a 0-16ms optimization, which might not be worth it, and the framework has ways to handle it if it is.
@noamr From https://developer.chrome.com/docs/web-platform/view-transitions/#async-dom-updates-and-waiting-for-content
The transition won't be started until the promise fulfills. During this time, the page is frozen, so delays here should be kept to a minimum.
Am I not right in thinking the page is visually frozen from the moment startViewTransition
fires its callback?
I need to play around with use-view-transitions
but if this is true surely updateState
is blocking whether or not it's wrapped in flushSync
.
Given this, I am looking to, with this PR, move as much work outside of startViewTransition
as possible when used with view libraries.
Sandbox example with use-view-transition
: https://codesandbox.io/s/upbeat-worker-y47h74?file=/src/App.js. This simulates a heavy page transition.
Run https://y47h74.csb.app/ with 6x CPU throttling enabled to see how this feels.
You can change startViewTransition
to document.startViewTransition
to see slightly different blocking behaviour but with either you can see we have an unresponsive page for the majority of this time.
What I'm looking for is to be able to move the bit where we freeze the page as close to the bit where we update the DOM as possible.
In this performance screenshot of the sandbox, the red box is how much time visual updates and interactions are blocked and the green box is how much time that could be with some kind of synchronous document.snapshotTransition()
. The proportions here are also somewhat exaggerated as every component in this tree has a useEffect
.
The performance characteristics of use-view-transitions
are actually much worse than using document.startViewTransition
.
Using the native API directly takes about 1.5 seconds in this demo from click to transition end, whereas using it via useViewTransition
takes about 4.8 seconds.
Likewise, with useViewTransition
, useEffect
callbacks are having to be flushed before the transition starts. Whereas with the native API they (correctly) aren't. Both probably because it's internal use of useState
?
Here's the same transition with document.startViewTransition
directly. Again the red box is the time the page is visually frozen and non-interactive. The green box is the time it would be frozen with my proposed API.
Thanks for the demo! I see that we were talking about two different meanings of "interruptible".
In the first image, there are less long tasks, so the main thread is in fact responsive in that way. That's what use-view-transition
does. It doesn't solve what you're presenting here though - "non-interruptible" here is the fact that the view is frozen after startVT
.
What you want to do here is to postpone the call to startVT
as much as possible to prevent a long duration of frozen rendering rather than blocked main thread. I see how this would be hard without something like global lifecycle hooks in React.
First of all, I can't find any mention of these snapshot callbacks in the React documentation. Do you have a link? Is this some deprecated thing?
I suspect that the thing with snapshotting here is that the DOM might be updated by someone other than React while React does all this "work" and perhaps updates suspense-fallbacks (loading spinners etc).
For example, you have these CSS animations running. If we do some sync snapshotting at the start, and then let animations keep running until we're ready to commit, wouldn't that create a jump? Could be that I'm not entirely getting the sync snapshotting thing yet.
I still think that the solution for this is something like React.startViewTransition
that does this internally, if React doesn't want to expose those hooks.
/cc @khushalsagar on this.
Yeah that's a good clear explanation, thanks.
Here are some snapshot lifecycles for popular view libraries:
getSnapshotBeforeUpdate
- not deprecated!beforeUpdate
beforeUpdate
For example, you have these CSS animations running. If we do some sync snapshotting at the start, and then let animations keep running until we're ready to commit, wouldn't that create a jump?
The animation wouldn't keep running, it would be paused for a much shorter amount of time.
const transition = document.snapshotViewTransition() // pause
transition.notifyUpdated() // start transition
I still think that the solution for this is something like React.startViewTransition that does this internally, if React doesn't want to expose those hooks.
From my perspective, rather than wait what is probably years for these view libraries to offer good APIs, by making this one API more fungible it could be solved in userland today. In the meantime we're going to have blocked UIs all over the web.
@mattgperry in the demo, could you include a console log at the point you'd like to call document.snapshotViewTransition()
?
Sorry for the delay, have been working further with View Transitions API.
There's not a place in the existing code where I would put the call. What I would do is have something like a PageTransition
that accepts the latest path.
<PageTransition path={latestUrl}>{content}</PageTransition>
This would be a React component that looks like
class PageTransition extends React.Component {
getSnapshotBeforeUpdate(prevProps) {
if (this.props.path !== prevProps.path) {
this.transition = document.snapshotViewTransition()
}
}
componentDidUpdate() {
if (this.transition) {
this.transition.notifyUpdated()
}
}
render() {
return this.props.children
}
}
An alternative could be adding a method to the ViewTransition
interface that allows authors to manually invoke the snapshot in a synchronous manner.
const transition = document.startViewTransition()
transition.snapshot()
updateDOM()
transition.notifyUpdated()
Or something. I think this small change would make it much more performant in real-world usage across existing view libraries for the reasons stated earlier.
Sorry for the late reply and thanks for clearly outlining the feature request @mattgperry!
The reason why it's hard to make capture synchronous is that we need to run the update the rendering loop for it. Making it synchronous would mean that this loop has to be triggered from within a script call, startViewTransition()
. That changes a fundamental assumption across all browsers that the rendering loop runs from a vsync driven new task on the main thread's event loop.
Browsers generally have the Document's last rendered frame cached. If we only had to snapshot the root, we could've provided that frame as the snapshot (it will be limited to the DOM state from the last rAF). But snapshotting elements independently affects layerization and a bunch of rendering decisions at every step in the pipeline. Its just not feasible to do it outside of a rendering loop. So we have to wait until the next rendering loop after startViewTransition()
is dispatched and that's why it has to be an async operation.
Can you clarify why async snapshotting is hard for frameworks to accommodate. It looks like you want to trigger a synchronous snapshot operation at the end of "big chunk of interruptible work". Since the big chunk of work is meant to be interruptible by design (for interactivity), it should be possible to yield for a frame between "big chunk of interruptible work" and "DOM Update". That gives the browser the opportunity to snapshot at the next frame. The delay after the next frame finishing and the updateCallback getting dispatched should be very less. Maybe I'm missing the complexity on the framework side here.
Thanks for explaining from the technical side @khushalsagar! It does sound infeasible. I felt like it could be like WAAPI where animated values aren’t resolved synchronously by default - but may be, should certain methods be called, essentially as a de-opt.
From the framework side it would - in my ignorant opinion - be relatively easy. But realistically speaking we have five or so popular frameworks that are probably going to move slowly on integrating with a currently narrowly supported API in order to “get it right”. Whereas I’m asking for the tools to solve this in userland so view transitions can be faster for all users today. Already we have libraries like Remix integrating it in a way that is not as performant as it would be with my suggested API available.
Problem
Currently, view transitions are triggered asynchronously.
Every example of view library integration shows
startViewTransition
wrapping some kind of state update function.The work performed within this state change is often heavy, notably when changing everything on a page (a common usecase for this API). It includes all the code within a render function, everything the view library does to compute the required DOM updates (diffing etc), the update itself, any layout effects etc.
So we are required to freeze the website (animations in most cases, scroll and other interactions) throughout all this work.
The vast majority of this work is, at least in React and probably in other libraries, usually interruptable. But not when we've frozen everything for a view transition.
Proposed solution
A synchronous alternative to snapshotting would allow any view library that supports before/after commit lifecycles to snapshot just before the DOM is updated, and start the animation just after it's commited. Moving all the calculation, diffing etc to before the snapshot.
Although the snapshot itself is now (optionally!) synchronous, moving all this work to before the snapshot means that view libraries like React that support time chunking and interruption will remain responsive. Likewise we will only snapshot changes that are actually going to happen.
Alternatives
It is of course possible that all view libraries that exist or could exist make it possible for the snapshot lifecycle to defer commit until a returned promise is resolved. Something like this?
But it's unlikely any will implement this just for the View Transitions API and practically by offering a synchronous alternative to the current API we can solve this across all view libraries right now.