Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

replace nextAnimationFrame by nextMicroTask #1042

Merged
merged 19 commits into from
Nov 1, 2023
Merged

Conversation

michelson
Copy link
Contributor

@michelson michelson commented Oct 20, 2023

DOM operations on turbostream are queued when the user is not in the browser tab or the tab is not active. This fixes that.
the problem resides when we call await nextAnimationFrame() because browsers pause the execution to save resources on the inactive browsers tab. The problem is that for chat applications or other kinds of notifications having a way to update things in the foreground is key.
more context at:
ref hotwired/turbo-rails#338

@brunoprietog
Copy link
Collaborator

This fixes #920.

Hopefully this will be taken into account in #1019, as mentioned by @seanpdoyle in this comment.

@afcapel
Copy link
Collaborator

afcapel commented Oct 24, 2023

This is subtly changing behavior because nextAnimationFrame and nextMicrotask have some other differences besides how the browser chooses to schedule them when the tab is not active. When you run something after requestAnimationFrame you are guaranteed that the event loop has run its UI rendering part, while putting a callback in the microtask queue executes it within the same event loop tick, before any UI changes have rendered.

To illustrate this point see this code:

<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="icon" href="data:,">
  <style>
    #box {
      width: 100px;
      height: 100px;
      background-color: blue;
      transition: all 1s;
    }
  </style>
  <script type="text/javascript">
    function nextAnimationFrame() {
      return new Promise((resolve) => requestAnimationFrame(() => resolve()))
    }

    function nextEventLoopTick() {
      return new Promise((resolve) => setTimeout(() => resolve(), 0))
    }

    function nextMicrotask() {
      return Promise.resolve()
    }

    document.addEventListener("DOMContentLoaded", async () => {
      const box = document.getElementById("box")

      box.style.transform = "translateX(500px)"
      await nextAnimationFrame()
      box.style.transform = "translateX(0px)"
    })
  </script>
</head>
<body>
  <div id="box"></div>
</body>
</html>

This animates a box moving from right to left.

animation.mov

However replacing nextAnimationFrame with nextMicrotask breaks the animation. The JS code sets translateX(500px) and translateX(0px) before the UI updates render, and so the property doesn't change between rendering steps and there's no animation.

The current uses of nextAnimationFrame are intended to let the browser render UI updates before carrying on with further DOM transformations and I'm wary that changing them can introduce subtle bugs.

However, I'd be interested to see an easy way to reproduce nextAnimationFrame not triggering to see if we can solve the issue in some other way.

@michelson
Copy link
Contributor Author

michelson commented Oct 24, 2023

Hi @afcapel , Thanks for your answer. I've tried your example and put it in codesandbox.
https://codesandbox.io/s/jolly-kare-yq5mmd?file=/index.html , I've also replaced the nextMicroask to nextEventLoopTick in your example, and the animation is executed correctly.

I've implemented this in this PR by replacing the nextMicroTask with nextEventLoopTick.

Unit tests are passing. But note that I had to keep the nextMicroTask on the stream_element.js; not sure why it failed when used with the nextEventLoopTick.

@afcapel
Copy link
Collaborator

afcapel commented Oct 30, 2023

@michelson sorry, I'm a wary of changing this behaviour because I find the possible repercussions very difficult to foresee.

Turbo already works well simplifying most simple use cases, but this one is particular enough that you may have to reach to lower level APIs. I think you're best chance is to reach to ActionCable to deliver the new message notifications.

You can reuse the same WebSocket connection that Turbo opens with the server, and do anything you want when a new message arrives, even if your tab is in the background.

This is how we do it in in one of our apps:

// consumer.js

import { cable } from "@hotwired/turbo-rails"
export default await cable.getConsumer() // this reuses the same websocket that Turbo uses for stream updates

// typing_notifications_channel.js

import consumer from "channels/consumer"

// Subscribe to a custom channel
export function subscribeToTypingNotificationsChannel(roomId, callbacks) {
  const channel = { channel: "TypingNotificationsChannel", room_id: roomId }
  return consumer.subscriptions.create(channel, callbacks)
}

// typing_notifications_controller.js

import { Controller } from "@hotwired/stimulus"
import { subscribeToTypingNotificationsChannel } from "channels/typing_notifications_channel"
import { pageIsTurboPreview } from "helpers/turbo_helpers"

// Reacts to new messages on the `TypingNotificationsChannel`
export default class extends Controller {
  connect() {
    if (!pageIsTurboPreview()) {
      this.channel = subscribeToTypingNotificationsChannel(this.roomIdValue, { received: this.#received.bind(this) })
    }
  }

  ...

  #received(data) {
    ... // handle received data
  }
}

Hope that helps and you can migrate your app away from React.

@afcapel afcapel closed this Oct 30, 2023
@michelson
Copy link
Contributor Author

michelson commented Oct 30, 2023

This are certain use cases might be affected or limited by this approach:

  1. Immediate Feedback: In scenarios where immediate feedback is crucial, even a slight delay introduced by nextAnimationFrame can be noticeable. For instance, real-time collaborative tools or games might need instantaneous reactions.

  2. Rapid Successive Updates: If there are rapid successive updates to the DOM, nextAnimationFrame might cause them to be bundled together. While this is often an optimization, there are scenarios where you might want updates to render as they come in, rather than waiting.

Chat applications have unique challenges and requirements that might be influenced by the use of nextAnimationFrame for rendering updates. Here are some points specific to chat applications:

  1. Instant Message Rendering: In real-time chat applications, messages from other users need to appear immediately once they're received. Any perceptible delay, even if it's minor, can detract from the "real-time" feel of the chat. nextAnimationFrame could potentially introduce such a delay.

  2. Message Order: If multiple messages are received in quick succession, it's crucial that they render in the correct order. The delay introduced by nextAnimationFrame could potentially cause issues if messages are processed in rapid succession but render out of order.

  3. Read Receipts: Many chat applications show when a message has been read by the other user. If the rendering of this status is delayed, it might confuse users or make the application feel less responsive.

  4. Typing Indicators: "User is typing..." indicators need to be real-time to be effective. If there's a delay in showing or hiding these indicators due to nextAnimationFrame, it can lead to miscommunication or confusion.

  5. Notifications and Alerts: For unread messages or mentions in a chat, instant notifications are crucial. Any delay in rendering these notifications could mean users might miss out on timely information or updates.

Considering these points, while nextAnimationFrame can optimize the rendering process for many web applications, chat applications have specific real-time needs that might be affected by such delays.

what if this could be an opt in feature? would you consider a new PR that implements that?

This is an idea to provide an optional configuration setting for Hotwire's Turbo to control whether it uses nextAnimationFrame for rendering updates. If the setting is enabled, Turbo would bypass nextAnimationFrame for more immediate rendering, especially useful for applications like real-time chats.

Here's a high-level overview of how we could implement this:

  1. Configuration Setting:
    Add a configuration option in Turbo to control the use of nextAnimationFrame.
Turbo.config = {
    ...,
    useNextAnimationFrame: true // default is true to maintain current behavior
};
  1. Update Turbo's Rendering Logic:
    In the part of the Turbo code that handles rendering, conditionally bypass nextAnimationFrame based on the configuration.
if (Turbo.config.useNextAnimationFrame) {
    requestAnimationFrame(() => {
        // current rendering logic
    });
} else {
    // immediate rendering logic
}

@michelson
Copy link
Contributor Author

Doing more tests, I believe that the only change needed is in the render function of StreamElement, so I'm guessing if the <turbo-stream action=replace target=id><template> element could have some indicator to change how it handles its render, like an attribute like: <turbo-stream render-timing="event-loop"> is that something that you would accept as a PR, @afcapel ?

@afcapel
Copy link
Collaborator

afcapel commented Nov 1, 2023

Not interested in configuration options, but I'm open to a more surgical change. We can try limiting the change to the StreamElement render function and using a new timing function that only skips nextAnimationFrame if the tab is hidden:

// util.js

export function nextRepaint() {
  if (document.visibilityState === "hidden") {
    return nextEventLoopTick()
  } else {
    return nextAnimationFrame()
  }
}

@afcapel afcapel reopened this Nov 1, 2023
@michelson
Copy link
Contributor Author

Great solution, @afcapel . On it.

@@ -43,7 +43,7 @@ export class StreamElement extends HTMLElement {
const event = this.beforeRenderEvent

if (this.dispatchEvent(event)) {
await nextEventLoopTick()
nextRepaint()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to await this? Should nextRepaint() return the next-prefixed Promise utilities instead of await-ing them?

src/core/index.js Outdated Show resolved Hide resolved
src/util.js Outdated Show resolved Hide resolved
@michelson
Copy link
Contributor Author

Hi @seanpdoyle, thanks for the suggestions. I've applied those already. Also, CI passing at https://github.com/michelson/turbo/actions/runs/6721616946/job/18267725557

@afcapel afcapel merged commit 3d48c86 into hotwired:main Nov 1, 2023
1 check passed
@afcapel
Copy link
Collaborator

afcapel commented Nov 1, 2023

Thanks @michelson & @seanpdoyle!

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

Successfully merging this pull request may close these issues.

4 participants