w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.46k stars 657 forks source link

[css-view-transitions-2] Allow synchronous snapshots #9400

Open mattgperry opened 1 year ago

mattgperry commented 1 year ago

Problem

Currently, view transitions are triggered asynchronously.

document.startViewTransition(() => {
  console.log(1)
})

console.log(2)

// 2, 1

Every example of view library integration shows startViewTransition wrapping some kind of state update function.

document.startViewTransition(() => {
  flushSync(() => setState(newState))
})

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.

Screenshot 2023-09-23 at 10 31 43

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.

class Component {
  snapshot() {
    this.transition = document.snapshotViewTransition()
  }

  updated() {
    this.transition.notifyUpdateComplete()
  }
}
Screenshot 2023-09-23 at 10 39 09

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?

class Component {
  snapshot() {
    let snapshotReady
    this.updated = new Promise()
    const promise = new Promise(resolve => {
      resolve = resolve
    })
    this.transition = document.startViewTransition(() => {
      snapshotReady()
      await this.updated
    })

    return promise
  }

  updated() {
    resolve this.updated
  }
}

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.

jakearchibald commented 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.

noamr commented 1 year ago

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.

jakearchibald commented 1 year ago

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"?

mattgperry commented 1 year ago

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: @.***>

mattgperry commented 1 year ago

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.

noamr commented 1 year ago

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 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.

mattgperry commented 1 year ago

@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.

mattgperry commented 1 year ago

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.

Screenshot 2023-09-26 at 14 41 19
mattgperry commented 1 year ago

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.

Screenshot 2023-09-26 at 14 46 48
noamr commented 1 year ago

Thanks for the demo! I see that we were talking about two different meanings of "interruptible".

About describing the problem

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.

About the proposed solution

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.

mattgperry commented 1 year ago

Yeah that's a good clear explanation, thanks.

Here are some snapshot lifecycles for popular view libraries:

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.

jakearchibald commented 1 year ago

@mattgperry in the demo, could you include a console log at the point you'd like to call document.snapshotViewTransition()?

mattgperry commented 11 months ago

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.

khushalsagar commented 11 months ago

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.

mattgperry commented 11 months ago

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.