hotwired / turbo

The speed of a single-page web application without having to write any JavaScript
https://turbo.hotwired.dev
MIT License
6.71k stars 427 forks source link

Caching does not work with FrontChat #391

Closed TastyPi closed 2 years ago

TastyPi commented 3 years ago

In our app (Rails 6) we use FrontChat for customer support. However, I'm having difficulties getting it to work with Turbo caching.

So far, I have the following:

application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <script src="https://chat-assets.frontapp.com/v1/chat.bundle.js" defer></script>
    <%= javascript_pack_tag "chat", "data-turbo-track": "reload", defer: true %>
  </head>

  <body> 
    <%= yield %>
    <div id="front-chat-container" data-turbo-permanent></div>
  </body>
</html>
chat.js
window.FrontChat('init', {chatId: '...', useDefaultLauncher: false})

This mostly works, the chat window stays open between page changes as I want, except when returning to a cached page I get an error Please pass a `chatId` or a `FCSP` variable.. This error does come from FrontChat, and I don't know the exact cause, however since the error only happens when loading a cached page I figured it might be of interest to the Turbo team.

Currently I'm working around this by using <meta name="turbo-cache-control" content="no-cache"> to avoid caching the page, and that works, but it would be nice to not have to disable caching.

For a bit of understanding of how FrontChat works, it grabs a reference to the #front-chat-container element and uses that to show/hide the chat window. It has to be a permanent element otherwise FrontChat breaks since it stores a reference to the element. I'm guessing there's some issue with cached pages and permanent elements.

tleish commented 3 years ago

See: Working with Script Elements

TastyPi commented 3 years ago

I've read that and tried several combinations with no success. The solution I provided keeps the same front-chat-container element, and having the scripts in the head means they should only get run once. Or does loading a cached page rerun the JavaScript in the head? I tried adding data-turbo-eval="false" to the two script tags but it didn't help.

It's possible FrontChat is listening for some other event, but I don't know what event would get triggered during a cached page load but not a new page load.

Edit: I've also now tried with no-preview instead of no-cache and get the same error, so it's definitely something specific to caching.

tleish commented 2 years ago

What if you wrap it with a turbo:load listener?

document.addEventListener("turbo:load", function() {
  window.FrontChat('init', {chatId: '...', useDefaultLauncher: false})
})
TastyPi commented 2 years ago

I've figured out the problem, FrontChat creates a <script> element inside the <body> when it is loaded, which it expects to only be evaluated once. According to https://turbo.hotwired.dev/handbook/building#working-with-script-elements, any script elements inside the <body> will be evaluated on every page load unless it has data-turbo-eval="false". I added some code to add data-turbo-eval="false" to the <script> after it is added and now it works with Turbo caching.

I'll contact them to suggest having the <script> be added to the <head> instead so people don't run into the same issue, but I don't think this is an issue that Turbo needs to be responsible for (unless you think a general solution to "libraries that add a script to body" is needed).

TastyPi commented 2 years ago

My full solution:

let script = document.createElement("script")

script.onload = () => {
  // Find the #front-chat-container added by Front
  let container = document.querySelector("#front-chat-container:not([data-turbo-permanent])")
  // Move the script element added by Front to <head>
  // See https://github.com/hotwired/turbo/issues/391#issuecomment-998895413
  document.head.appendChild(container.nextSibling)
  // Remove the container added by Front. We have our own #front-chat-container[data-turbo-permanent] which ensures
  // Turbo keeps the chat open during navigation.
  container.remove()
  window.FrontChat('init', {...});
}
script.defer = true
script.src = "https://chat-assets.frontapp.com/v1/chat.bundle.js"
document.head.appendChild(script)

It's definitely a hacky workaround, but it seems to work.

TastyPi commented 1 year ago

In case anyone finds the same issue, Front has recently updated to use an iframe instead, and the previous workaround fails. This is my new workaround:

const FRONT_ELEMENT_ID = "front-chat-iframe"

function frontRenderWorkaround(event: TurboBeforeRenderEvent) {
  // Front's chat widget adds an iframe to the body. This iframe must never be reloaded otherwise Front irreparably
  // breaks - no combination of FrontChat("shutdown") before removal or FrontChat("init") after removal or even after
  // being added back fixes this.
  //
  // The reason is the iframe is added on the initial load of Front's SDK, *not* when FrontChat("init") is called, and
  // if the iframe is reloaded then the connection between it and the library is broken. Unfortunately iframes are
  // reloaded whenever they are removed from the DOM, even if they are just moved to a different part of the DOM.
  //
  // Turbo's default rendering logic creates a new body element, so it is impossible keep the iframe element in the DOM
  // without using custom rendering logic.
  //
  // The workaround is to use morphdom as the renderer, which will modify the existing DOM in-place to match the new
  // DOM. It also provides a way to prevent the iframe being removed when it is not included in the new DOM.

  // Remove the cached Front Chat iframe
  // Ideally this would be done using data-turbo-cache=false, but that removes it from the DOM before rendering starts
  event.detail.newBody.querySelector(`#${FRONT_ELEMENT_ID}`)?.remove()

  // Replace the renderer with morphdom, and prevent the Front Chat iframe from being removed
  event.detail.render = (newElement, currentElement) =>
    morphdom(newElement, currentElement, { onBeforeNodeDiscarded: (node) => node.id !== FRONT_ELEMENT_ID })
}

document.addEventListener("turbo:before-render", frontRenderWorkaround)
shahafabileah commented 1 year ago

I'm also running into this issue now. @TastyPi thanks for posting your new workaround. I tried it and it doesn't seem to fully work. The issue is here:

  event.detail.newBody.querySelector(`#${FRONT_ELEMENT_ID}`)?.remove()

That query returns nothing.

Does your payload from the server include the <iframe>? Or is this a timing thing, where some other code is running client-side and causing the <iframe> to be added to newBody before this logic runs?

shahafabileah commented 10 months ago

In case this is still causing issues for people, the Front support team recently shared the following code with us:

import { FC, useEffect, useRef } from "react";
"use client";
declare global {
    interface Window {
        FrontChat: FrontChat | undefined;
    }
}
/* Types */
interface FrontChatOptions {
    nonce?: string;
}
type UnbindHandler = () => void;
type FrontChatParams = Record<string, string | boolean | object>;
type FrontChat = (cmdType: string, params?: FrontChatParams) => UnbindHandler | undefined;
const scriptSrc = "https://chat-assets.frontapp.com/v1/chat.bundle.js";
export const FrontChatLoader: FC = () => {
    const isChatLoadedRef = useRef(false);
    useEffect(() => {
        if (isChatLoadedRef.current) {
            return;
        }
        isChatLoadedRef.current = true;
        boot()
            .then((FrontChat) => {
                FrontChat("init", {
                    chatId: "your_chatId_here",
                });
            })
            .catch(console.error);
    }, []);
    return null;
};
/* Helpers */
export async function boot(element?: HTMLElement, options?: FrontChatOptions): Promise<FrontChat> {
    const scriptTag = document.createElement("script");
    scriptTag.setAttribute("type", "text/javascript");
    scriptTag.setAttribute("src", scriptSrc);
    if (options?.nonce) {
        scriptTag.setAttribute("nonce", options.nonce);
    }
    const scriptContainer = element ?? document.body;
    const loadScriptPromise = new Promise<FrontChat>((resolve) => {
        scriptTag.onload = () => {
            if (!window.FrontChat) {
                throw new Error("[front-chat-sdk] Could not set up window.FrontChat");
            }
            resolve(window.FrontChat);
        };
    });
    scriptContainer.appendChild(scriptTag);
    return loadScriptPromise;
}

We don't use React, but I borrowed the key concepts from there and did the following, which seems to work for us, both on initial page load and after turbo navigation:

  async function loadFrontChatJS() {
    const scriptTag = document.createElement("script");
    scriptTag.setAttribute("type", "text/javascript");
    scriptTag.setAttribute("src", "https://chat-assets.frontapp.com/v1/chat.bundle.js");
    const scriptContainer = document.body;
    const loadScriptPromise = new Promise((resolve) => {
      scriptTag.onload = () => {
        if (!window.FrontChat) {
          throw new Error("[front-chat-sdk] Could not set up window.FrontChat");
        }
        resolve(window.FrontChat);
      };
    });
    scriptContainer.appendChild(scriptTag);
    return loadScriptPromise;
  }

  function initFrontChat(FrontChat) {
    FrontChat("init", {
      chatId: <fill_this_in>,
      useDefaultLauncher: <fill_this_in>,
      email: <fill_this_in>,
      userHash: <fill_this_in>,
      name: <fill_this_in>,
    });
  }

  if (!window.FrontChat) {
    loadFrontChatJS().then(initFrontChat).catch(console.error);
  } else {
    initFrontChat(window.FrontChat);
  }

We're on turbo-rails (1.0.1)

Vilos92 commented 10 months ago

I'm glad that the code snippet worked for you @shahafabileah 😄

For anyone else who might run into issues, today our team published this public repository that has examples of running Front Chat both in a React application, as well as in any general web application: https://github.com/frontapp/front-chat-sdk