How I turned Local Storage into a Web Socket

How I turned Local Storage into a Web Socket

ยท

8 min read

Featured on Hashnode

Your first question after reading the title must be WHY!?

Well, let me explain the why before we jump into the how!

For those of you who do not know me, I am the creator of Remix Development Tools, and for the past few weeks, I have been working on a detached mode feature where the users will be able to detach their dev tools from the browser window and use it as usual. For this to work I needed some way for the two browser windows to talk to each other. I always prefer to, as we Remixers say, "abuse the platform", so naturally my first thought was:

"Is there a way to do this without pulling in third party solutions or packages?"

Well, this is where my advanture starts. My first thought was to ask people in the Remix discord about what would they use and I got an interesting suggestion:

"Have you tried window.open()?"

After reading through the documentation of window.open() I realized this is Exactly what I need. I implemented it in the code and I had it working fine by opening the dev tools in a new popup window, positioned it where the dev tools usually are and made the width and height the same as before.

The implementation looked like the following:

const rdtWindow = window.open(
  window.location.href,
  "",
  `popup,width=${window.innerWidth},height=${settings.height},top=${window.screen.height},left=${window.screenLeft}}`
);

if (rdtWindow) {
  setDetachedWindowOwner(true);
  setStorageItem(REMIX_DEV_TOOLS_IS_DETACHED, "true");
  setSessionItem(REMIX_DEV_TOOLS_DETACHED_OWNER, "true");
  rdtWindow.RDT_MOUNTED = true;
}

Pretty simple, I open up the new window, make sure it's opened by checking if the window proxy object is returned by the method and then I set the flag on the proxy window so that it knows it's mounted and I store that this window is the owner of the opened window and the initial mounted state is done!

I added listeners to the proxy window object. Whenever changes happened it reported back to the original window via a listener and everything was working fine. I had a two-way communication setup via custom window events and things were starting to wrap up and the implementation was looking solid!

Well that wraps up the implementation, right? Oh, I wish that was the case.

After refreshing the window I realized there is no way to persist that proxy window across multiple client reloads and as soon as you refresh/unmount/remount the window it is gone and I'm left with a zombie popup hanging around and having a good time. ๐Ÿ‘€

So my first natural reaction is to turn to the MDN docs and try to find something that would work, and my first idea is looking at how specifically session and local storage work when it comes to sharing data between opened tabs/windows. After a while I stumbled upon this sentence:

"The StorageEvent is fired whenever a change is made to the Storage object (note that this event is not fired for sessionStorage changes)"

After reading this a lightbulb lit in my head:

"This is it! I can communicate via web storage between the two tabs whenever changes happen!"

After that, I had to create 4 things:

  1. Syncing states between the two tabs

  2. Syncing route changes

  3. Knowing if a tab is still detached or not

Syncing states between the two tabs

The first one was pretty straightforward because I use a global React Context in the dev tools I added this simple method in the root of my provider:

// Global context provider for the whole page
export const RDTContextProvider = ({ children }: ContextProps) => {
  const [state, dispatch] = useReducer(rdtReducer, getExistingStateFromStorage());
  const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);

  // This effect stores the whole reducer state into local storage after each change
  useEffect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { settings, detachedWindow, detachedWindowOwner, ...rest } = state;
    // Store user settings for dev tools into local storage
    setStorageItem(REMIX_DEV_TOOLS_SETTINGS, JSON.stringify(settings));
    // Store general state into local storage
    setStorageItem(REMIX_DEV_TOOLS_STATE, JSON.stringify(rest));
  }, [state]);

  return <RDTContext.Provider value={value}>{children}</RDTContext.Provider>;
};

This stored all my state and user settings in local storage. and then because the detached window is running the identical code all I had to do to store the data in it's context was the following:

const refreshRequiredKeys = [REMIX_DEV_TOOLS_SETTINGS, REMIX_DEV_TOOLS_STATE];

const useSyncStateWhenDetached = () => {
  const { dispatch, state } = useRDTContext();

  useAttachListener("storage", "window", (e: any) => {
    // Not caused by the dev tools
    if (!refreshRequiredKeys.includes(e.key)) {
      return;
    }
    // Check if the settings have not changed and early return
    if (e.key === REMIX_DEV_TOOLS_SETTINGS) {
      const oldSettings = JSON.stringify(state.settings);
      if (oldSettings === e.newValue) {
        return;
      }
    }
    // Check if the state has not changed and early return
    if (e.key === REMIX_DEV_TOOLS_STATE) { 
      const { settings, ...rest } = state;
      const oldState = JSON.stringify(rest);
      if (oldState === e.newValue) {
        return;
      }
    }

    // store new state
    const newState = getExistingStateFromStorage();
    dispatch({ type: "SET_WHOLE_STATE", payload: newState });
  });
};

And there it is! Full two-sided real-time synchronization between the separate windows. Because the storage listener works for all tabs BUT the one that set the new state this meant when one tab is setting the state the other one is getting the storage events and vice versa. What does this mean? We have WebSocket-like behavior using Local Storage!

Syncing route changes

After the first requirement was met the others were easy to pull off! For requirement number two it was more of the same, I implemented the following hook:

 const useListenToRouteChange = () => {
  const { detachedWindowOwner } = useDetachedWindowControls();
  const location = useLocation();
  const navigate = useNavigate();
  const navigation = useNavigation();
  const locationRoute = location.pathname + location.search;
  const navigationRoute = (navigation.location?.pathname ?? "") + (navigation.location?.search ?? "");
  const ref = useRef(locationRoute);
  const route = getRouteFromLocalStorage();

  // Used by the owner window only
  useEffect(() => {
    // If the route changes and this is the original window store the event into local storage
    if (route !== locationRoute && detachedWindowOwner) {
      setRouteInLocalStorage(locationRoute);
    }
  }, [locationRoute, detachedWindowOwner, route]);

  // Used to sync the route between the routes
  useAttachListener("storage", "window", (e: any) => {
    // We only care about the key that changes the route
    if (e.key !== LOCAL_STORAGE_ROUTE_KEY) {
      return;
    }

    const route = getRouteFromLocalStorage();
    // Make sure the route is different and this hasn't been triggered already and there is no navigation going on
    if (route && route !== ref.current && route !== navigationRoute && navigation.state === "idle") {
      ref.current = route;
      navigate(route);
    }
  });
};

And there we have it, full logic for two-sided communication on route changes. Now whenever the user navigates on the original application window the detached app redirects too so the "Page tab" is up to date with its routes and content.

Knowing if a tab is still detached or not

Knowing if the tab is detached was a bigger challenge, if you've been around the web development block for a while you've probably heard of the dreaded "beforeunload" window listener!!

It triggers on refresh, remounts, unmounts, you name it! And the best part? You have no idea what triggered it. I searched the web for a solution for almost a week, nothing worked, whatever I did had flaws, I first tried to implement a global variable which would be set and changed on certain events.

I added a listener to every <a>, <button>, <form> on the page and set the global variable which we will name as isValidRemount to false. After a few trial and errors this was relatively working, but then I realized I had no idea of knowing if the window is being remounted by a HMR, refreshed, or closed.

This approach was a no-go! What the heck do I do now?

Then it dawned on me, I will use something like a "TLS handshake-like" approach that networking engineers are probably aware of.

On each mount of either of the windows I do the following:

setStorageItem(REMIX_DEV_TOOLS_CHECK_DETACHED, "false")

On each unmount of either of the windows I do the following:

export const useResetDetachmentCheck = () => {
  const { isDetached } = useDetachedWindowControls(); 
  useAttachListener("unload", "window", () => setStorageItem(REMIX_DEV_TOOLS_CHECK_DETACHED, "true"), isDetached);
};

This sets the "check if I am detached" flag to true which triggers an event listener on the other tab:

 const checkDetachment = useCallback(
    (e: StorageEvent) => {
      // We only care about the should_check key
      if (e.key !== REMIX_DEV_TOOLS_CHECK_DETACHED) {
        return;
      }

      const shouldCheckDetached = getBooleanFromStorage(REMIX_DEV_TOOLS_CHECK_DETACHED);

      // If the detached window is unloaded we want to check if it is still there
      if (shouldCheckDetached && !checking) {
        // Give the window some time to remount
        setTimeout(() => setChecking(true), 200);
      }
    },
    [checking]
  );
  // Attach the listener
  useEffect(() => {
    if (checking || !isDetached) {
      return;
    }

    addEventListener("storage", checkDetachment);
    return () => removeEventListener("storage", checkDetachment);
  }, [checking, isDetached, checkDetachment]);

So what does this hook do? Very simple, it triggers a check if the other window is still there after 200ms by the following effect:

 useEffect(() => {
    if (!checking || !isDetached) {
      return;
    }

    // On reload the detached window will set the flag back to false so we can check if it is still detached
    const isNotDetachedAnymore = getBooleanFromStorage(REMIX_DEV_TOOLS_CHECK_DETACHED);
    // The window hasn't set it back to true so it is not detached anymore and we clean all the detached state
    if (isNotDetachedAnymore) {
      setStorageItem(REMIX_DEV_TOOLS_IS_DETACHED, "false");
      setStorageItem(REMIX_DEV_TOOLS_CHECK_DETACHED, "false");
      sessionStorage.removeItem(REMIX_DEV_TOOLS_DETACHED_OWNER);
      sessionStorage.removeItem(REMIX_DEV_TOOLS_DETACHED);
      const state = getExistingStateFromStorage();
      dispatch({ type: "SET_WHOLE_STATE", payload: state });
      setChecking(false);
    }
  }, [checking, dispatch, isDetached]);

And there you have it! I have implemented a way for each tab to check if the other one is alive on each remount of either one using Local Storage!

Thank you!

If you've reached the end you're a champ! Hope you liked my article, I plan on adding more whenever I use some interesting approach to accomplish something cool.

If you wish to support me follow me on Twitter here:

https://twitter.com/AlemTuzlak59192

And you can also sign up for the newsletter to get notified whenever I publish something new!

Also, you can find the Remix Development Tools and the full implementation here:

https://github.com/Code-Forge-Net/Remix-Dev-Tools

And finally my Github here:

https://github.com/AlemTuzlak

ย