Widget state synchronisation across tabs

Labeeb Latheef

By Labeeb Latheef

on July 2, 2024

The neetoChat widget is the end-user-facing companion widget of our neetoChat application. By embedding the neetoChat widget on their website, neetoChat users can easily interact with their customers in real-time.

neetoChat chat screen

The neetoChat widget has a feature to synchronize its state across other widget instances you might have open in any other tab or window, in real-time. This ability gives users the illusion of interacting with the same widget instance across tabs and provides a sense of continuity. For example, user interactions such as navigating to another page, and minimizing/maximizing the widget performed on a widget instance in one tab are reflected across widgets in every other tab.

In fact, a widget or the underlying script cannot be shared between multiple browser contexts (tabs, windows, etc). All scripts in a context run in an isolated environment, strictly separate from other executing contexts. However, there are methods we can use to enable communication between two browser contexts. Some of the popular choices are listed below. With a proper communication channel set in place, two widget instances can notify each other about their user interactions and state updates in real time to be synchronized.

  1. Use BroadcastChannel API
  2. Use localStorage change listener
  3. Use window.postMessage() method
  4. Use Service Worker Post Message

With ease of implementation and compatibility across different browser environments in mind, we have chosen to implement this feature using the localStorage change listener. The details of the implementation can be split into two parts.

1. Navigation Checkpoint:

When opening a new widget instance in a new tab or window, the widget resumes from the last checkpoint, allowing the users to continue from where they left off. This implementation is pretty straightforward. Whenever the URL pathname changes, we keep a reference in the localStorage called "checkpoint".

1const Router = ({ children }) => {
2  const history = useHistory();
3  const pathname = history.location.pathname;
4
5  const addCheckPoints = pathname => {
6    // Check if the current pathname matches any allowed routes.
7    // Only navigations to main pages are allowed to be added as checkpoints.
8    const isAllowed = ALLOWED_CHECKPOINT_ROUTES.some(route =>
9      matchPath(pathname, { path: route, exact: true, strict: true })
10    );
11    // Writes the pathname to localStorage with a unique identifier
12    if (isAllowed) localStorage.setItem("checkpoint", pathname);
13  };
14
15  // Run for every pathname changes
16  useEffect(() => {
17    addCheckPoints(pathname);
18  }, [pathname]);
19
20  return <App />;
21};

Now, upon initializing a new widget instance, we check localStorage for any existing checkpoint and set this as the initial route, presenting the user with the same screen they visited last time.

1const history = useHistory();
2
3// Run on initial mount
4useEffect(() => {
5  const checkpoint = localStorage.getItem("checkpoint");
6  // Replace initial route with checkpoint from localStorage.
7  if (checkpoint) history.replace(checkpoint);
8}, []);
2. Real-time Synchronisation:

The above implementation only allows a new widget instance to start in the last checkpoint. From this point onwards, each state updates (minimize, maximize), and user navigation in one widget has to be synchronized in real time with active widget instances in other tabs to maintain the same appearance across the tabs. At the core, this communication is enabled by adding a custom React hook called useWindowMessenger. The useWindowMessenger hook relies on localStorage values and localStorage change listeners for sending messages or events across different browser contexts.

1const storageKey = `__some_unique_localStorage_key__`;
2const origin = window.location.origin;
3const windowId = uuid(); // Assigns unique id for each browser contexts.
4
5const createPayload = message => {
6  const payload = {};
7  payload.message = message;
8  payload.meta = {};
9  payload.meta.origin = origin;
10  payload.meta.window = windowId;
11  return JSON.stringify(payload);
12};
13
14const useWindowMessenger = messageHandler => {
15  const messageHandlerRef = useRef();
16  messageHandlerRef.current = messageHandler;
17
18  const sendMessage = useCallback(message => {
19    const payload = createPayload(message);
20    // A new item is updated in local storage and immediately removed,
21    // This is sufficient to get the 'storage' event to be fired.
22    localStorage.setItem(storageKey, payload);
23    localStorage.removeItem(storageKey);
24  }, []);
25
26  useEffect(() => {
27    if (typeof messageHandlerRef.current !== "function") return;
28
29    const handleStorageChange = event => {
30      if (event.key !== storageKey) return;
31      if (!event.newValue) return;
32      const { message, meta } = JSON.parse(event.newValue);
33      // Every window has a unique `windowId` attached.
34      // If event originated from the same `window`, the event is ignored.
35      if (meta.window === windowId || meta.origin !== origin) return;
36      messageHandlerRef.current(message);
37    };
38
39    // `storage` event is fired whenever value updates are sent to localStorage
40    window.addEventListener("storage", handleStorageChange);
41    return () => {
42      window.removeEventListener("storage", handleStorageChange);
43    };
44  }, [messageHandlerRef]);
45
46  return sendMessage;
47};
48
49export default useWindowMessenger;

In essence, the useWindowMessenger hook returns a sendMessage function that can be used to send a message to widget instances in other tabs. Also, it accepts a messageHandler callback that can receive and handle the messages sent by other instances.

Now, when one widget instance emits state and navigation change events, other widget instances handle these events to make necessary updates to their internal state to mirror the changes. Below is a simplified example of how this was done in the neetoChat widget.

1import { useCallback } from "react";
2import { useHistory } from "react-router-dom";
3
4// useLocalStorage is an in-house implementation that sync its state value into localStorage and restores the last value on next load.
5import useLocalStorage from "@hooks/useLocalStorage";
6import useWindowMessenger from "@hooks/useWindowMessenger";
7
8export const MESSAGE_TYPES = {
9  STATE_UPDATE: "STATE_UPDATE",
10  PATH_UPDATE: "PATH_UPDATE",
11};
12
13const useWidgetState = () => {
14  // This internal state controls widget visibility and other behaviours.
15  const [widgetState, setWidgetState] = useLocalStorage(
16    "widgetState", // Unique localStorage key
17    { maximized: false }
18  );
19  const history = useHistory();
20  const pathname = history.location.pathname;
21
22  const sendMessage = useWindowMessenger(message =>
23    handleMessageTypes(message.type, message.payload)
24  );
25
26  const handleMessageTypes = (type, payload) => {
27    switch (type) {
28      // Actions such as minimize, maximize are received as "STATE_UPDATE"
29      case MESSAGE_TYPES.STATE_UPDATE:
30        // Payload contains new state values
31        // Commit new value updates to the internal state that controls widget.
32        setWidgetState(prevState => ({ ...prevState, ...payload }));
33        break;
34
35      // User navigation actions are received as "PATH_UPDATE"
36      case MESSAGE_TYPES.PATH_UPDATE:
37        // Payload contains new pathname
38        // Navigate to page if not already in the same page.
39        if (history.location.pathname !== payload) history.push(payload);
40        break;
41      default:
42        console.warn(`Unhandled message type: ${type}`);
43    }
44  };
45
46  // This function extends `setWidgetState` function by adding the ability to emit `STATE_UPDATE` event for each state update call.
47  const updateWidgetState = useCallback(async update => {
48    let nextState;
49    await setWidgetState(prevState => {
50      nextState = { ...prevState, ...update };
51      return nextState;
52    });
53    sendMessage({ type: MESSAGE_TYPES.STATE_UPDATE, payload });
54  }, []);
55
56  // Send "PATH_UPDATE" event for every path changes in the active widget.
57  useEffect(() => {
58    sendMessage({
59      type: MESSAGE_TYPES.PATH_UPDATE,
60      payload: pathname,
61    });
62  }, [pathname]);
63
64  return [widgetState, updateWidgetState];
65};

With the above setup, all the widget instances in different tabs act like a single widget by mirroring the actions from the active instance.

Stay up to date with our blogs. Sign up for our newsletter.

We write about Ruby on Rails, ReactJS, React Native, remote work,open source, engineering & design.