Blog
Client-side Browser Window Communication

Client-side Browser Window Communication

✍️ You have to connect the dots

A while ago, I stumbled upon a very impressive demo of an orb that stretches across browser windows.

Putting the graphics aside, the part I took interest in was “how are the windows actually communicating their position to each other?”. My initial guess was sockets, but I later learned it was all client-side through localStorage.

This particular aspect of the demo is what I’ll be expanding on in this post. We’re going to explore different methods for fully client-side cross-browser window communication using a (less flashy) example of a network of lines drawn across browser windows. Each window will draw a line from itself to all other windows in the network, forming a complete graph.

Shared Memory vs. Message Passing

Let’s back out of the web world and think about interprocess communication (IPC). There’s two main paradigms for IPC at an OS level: shared memory and message passing. In the shared memory model, processes share a common memory space that they read/write to in order to pass data. With this approach, proper synchronization is paramount to prevent integrity issues or race conditions. However, this approach is fast because it doesn’t require crossing the kernel boundary.

In the message passing model, processes communicate by sending messages to each other (through the kernel). Though slower, this approach is more resilient to synchronization issues.

Back to the web world. We can draw the analogy that our multiple browser windows are separate processes on the OS. What would they use for shared memory and message passing?

  • Shared memory: localStorage, a key-value store that is shared across all windows of the same origin.

  • Message passing: the BroadcastChannel API, which allows us to send messages between windows of the same origin.

Graphics and Window Positioning

First, let’s take care of some boilerplate that we’ll need for detecting the window position, stably identifying the window, and drawing lines. Each window will be the same instance of a Vite React app. React is certainly not necessary for this, but I’m using it to avoid cluttering this post with the additional boilerplate of manual DOM updates.

This section isn’t the core of what this post is about, so feel free to skim over it.

Window positioning

In order to draw lines between windows, we need to know the center coordinates of each window relative to the entire screen. The window.screenX and window.screenY properties give us left and right offsets of the top-left corner of the window relative to the top-left corner of the screen. Then, window.innerWidth and window.innerHeight give us the width and height of the window respectively.

Window dimensions

So first, let’s write a hook that gives us the realtime center position of the window relative to the screen. Unfortunately, there’s no events fired for window movement, so either we attempt to track window movement using “resize” and “mouse” events, or we poll the window dimensions. Let’s go with the latter:

hooks/use-window-dimensions.ts
import { useEffect, useState } from "react";
import { deepEquals } from "../utils/deep-equals";
 
type WindowDimensions = {
  screenX: number;
  screenY: number;
  innerWidth: number;
  innerHeight: number;
};
 
export function useWindowDimensions(): WindowDimensions {
  const [dimensions, setDimensions] = useState<WindowDimensions>({
    screenX: window.screenX,
    screenY: window.screenY,
    innerWidth: window.innerWidth,
    innerHeight: window.innerHeight,
  });
 
  useEffect(() => {
    const pollPositionInterval = setInterval(() => {
      const newPosition: WindowDimensions = {
        screenX: window.screenX,
        screenY: window.screenY,
        innerWidth: window.innerWidth,
        innerHeight: window.innerHeight,
      };
 
      if (deepEquals(newPosition, dimensions)) return;
 
      setDimensions(newPosition);
    }, 50);
 
    return () => clearInterval(pollPositionInterval);
  }, [setDimensions, dimensions]);
 
  return dimensions;
}

This hook polls the window dimensions every 50 milliseconds and updates the state if there’s a change.

Let’s try to pull this into our main component calculate the center point of the window on the screen. From the graphic above, we can see that the center point can be written as:

# Using Math.floor to avoid any subpixel nonsense
centerX = Math.floor(screenX + innerWidth / 2)
centerY = Math.floor(screenY + innerHeight / 2)

Let’s calculate this in the component and display it in the corner of the window:

App.tsx
import { useMemo } from "react";
import { useWindowDimensions } from "./hooks/use-window-dimensions";
 
function App() {
  const { screenX, screenY, innerWidth, innerHeight } = useWindowDimensions();
 
  const currentCenter = useMemo(
    () => ({
      x: Math.floor(screenX + innerWidth / 2),
      y: Math.floor(screenY + innerHeight / 2),
    }),
    [screenX, screenY, innerWidth, innerHeight]
  );
 
  return (
    <div className="h-screen w-screen overflow-hidden">
      <div className="absolute top-1 left-1 p-2 text-white text-xs font-mono bg-black bg-opacity-50">
        Pos: ({currentCenter.x}, {currentCenter.y})
      </div>
    </div>
  );
}
 
export default App;

And looks like it works! We have the center position of the window displayed in the top left corner.

Stable window identification

No matter the mechanism the windows communicate with, they’ll need a way to identify themselves when sending messages, as well as identifying other windows that they’re communicating with. To keep things simple, we’ll generate a UUID outside of the React life cycle and return it in a hook so it’s constant and unique per window.

hooks/use-window-id.ts
import { v4 as uuid } from "uuid";
 
const windowId = uuid();
 
export function useWindowId(): string {
  return windowId;
}

Drawing lines

Alright, the last boilerplate we’ll need is the system for drawing lines. We could use a <canvas> here, but I’ll go the pure DOM route and use <svg> with <line> instead. All we need to specify a line is the start and end coordinates. Our <svg> will take up the entire window.

Let’s draw a line that starts at the center of the window and goes towards the top-left corner. When drawing lines, we have to specify coordinates in relation to the top-left corner of the window. We can’t use the currentCenter we derived earlier as the initial point because it’s relative to the screen, not the window. To adjust the screen coordinate system to the window coordinate system, we just have to subtract the screenX and screenY from the currentCenter coordinates.

App.tsx
import { useMemo } from "react";
import { useWindowDimensions } from "./hooks/use-window-dimensions";
 
function App() {
  const { screenX, screenY, innerWidth, innerHeight } = useWindowDimensions();
 
  const currentCenter = useMemo(
    () => ({
      x: Math.floor(screenX + innerWidth / 2),
      y: Math.floor(screenY + innerHeight / 2),
    }),
    [screenX, screenY, innerWidth, innerHeight]
  );
 
  return (
    <div className="h-screen w-screen overflow-hidden">
      <div className="absolute top-1 left-1 p-2 text-white text-xs font-mono bg-black bg-opacity-50">
        Pos: ({currentCenter.x}, {currentCenter.y})
      </div>
      <svg className="h-full w-full">
        <line
          x1={currentCenter.x - screenX}
          y1={currentCenter.y - screenY}
          x2={5}
          y2={5}
          stroke="#4e46e5"
          strokeWidth="6"
          strokeLinecap="round"
        />
      </svg>
    </div>
  );
}
 
export default App;

Nice! Our line starts in the center of the window even when we move it.

Shared Memory Approach

With the core graphics logic done, let’s start working on the communication aspect. We’ll start with the shared memory approach using localStorage.

Each window will write it’s center point to localStorage whenever it changes, If each window does this, the entire state of the window network will be held in localStorage — thus, each window will also read from localStorage to get the positions of the other windows and draw the lines accordingly.

Broadcasting current position

Whenever a window spawns or changes location, it needs to let other windows know that it’s center location changed. As mentioned, this broadcasting will via writing the current center to localStorage. We have two options here:

  1. Each window maintains a separate key in localStorage where it stores its center point.
  2. We maintain a single key in localStorage where we store the center points of all windows, and each window updates this key.

Let’s going with option 1 so that we avoid problems with thrashing a single key-val.

First, each window only needs to deposit a single piece of information: its center point. Let’s define a type for Point:

utils/types.ts
export type Point = { x: number; y: number };

Now, let’s flesh out some abstractions for accessing localStorage. We’ll need functions for creating a key, writing a key-value, and clearing a key-value:

utils/storage.ts
import { Point } from "./types";
 
const STORAGE_KEY_PREFIX = "@@window-communication";
 
export function createKey(windowId: string): string {
  return `${STORAGE_KEY_PREFIX}:${windowId}`;
}
 
export function getWindowCenters(): Record<string, Point> {
  return Object.entries(localStorage).reduce<Record<string, Point>>(
    (acc, [key, value]) => {
      acc[key] = JSON.parse(value);
      return acc;
    },
    {}
  );
}
 
export function setCenter(windowId: string, position: Point): void {
  const key = createKey(windowId);
  localStorage.setItem(key, position);
}
 
export function clearCenter(windowId: string): void {
  const key = createKey(windowId);
  localStorage.removeItem(key);
}

We now have tools to set and clear centers for a window. Each window’s data is namespaced under the "@@window-communication" key prefix, and uniquely identified by the window’s ID.

Let’s now write a hook that “broadcasts” the current window’s center position whenever it changes by writing to localStorage.

hooks/shared-memory/use-broadcast-window-center.ts
import { useEffect } from "react";
import { clearCenter, setCenter } from "../../utils/storage";
import { Point } from "../../utils/types";
import { useWindowId } from "../use-window-id";
 
export function useBroadcastWindowCenter(center: Point): void {
  const windowId = useWindowId();
 
  useEffect(() => {
    setCenter(windowId, center);
  }, [windowId, center]);
}

One more thing, we also need to clear the center position whenever the window closes or the window ID changes. This will avoid having our graphic have stray lines to windows that don’t exist anymore. Let’s set another effect that does this.

hooks/shared-memory/use-broadcast-window-center.ts
export function useBroadcastWindowCenter(center: Point): void {
  const windowId = useWindowId();
 
  useEffect(() => { 
    window.onbeforeunload = () => clearCenter(windowId); 
    return () => { 
      window.onbeforeunload = () => {}; 
      clearCenter(windowId); 
    }; 
  }, [windowId]); 
 
  useEffect(() => {
    setCenter(windowId, center);
  }, [windowId, center]);
}

Now all we have to do is wire it up to our main component:

App.tsx
import { useMemo } from "react";
import { useWindowDimensions } from "./hooks/use-window-dimensions";
 
function App() {
  const { screenX, screenY, innerWidth, innerHeight } = useWindowDimensions();
 
  const currentCenter = useMemo(
    () => ({
      x: Math.floor(screenX + innerWidth / 2),
      y: Math.floor(screenY + innerHeight / 2),
    }),
    [screenX, screenY, innerWidth, innerHeight]
  );
 
  useBroadcastWindowCenter(currentCenter); 
 
  /* ... */
}
 
export default App;

And we can see that localStorage updates whenever the window position changes, and clears when the window closes! If we have multiple windows, they each write to their own key!

Reading position of other windows

With each window broadcasting its position, we now need a way for each window to read in the positions of all windows except itself — its neighbors.

Let’s start by adding a function that gets all the windows’ center positions by parsing through all localStorage keys and pulling the key-values that match our namespace.

utils/storage.ts
import { Point } from "./types";
 
const STORAGE_KEY_PREFIX = "@@window-communication";
 
export function createKey(windowId: string): string {
  return `${STORAGE_KEY_PREFIX}:${windowId}`;
}
 
export function getWindowCenters(): Record<string, Point> { 
  return Object.entries(localStorage).reduce<Record<string, Point>>( 
    (acc, [key, value]) => { 
      if (!key.startsWith(STORAGE_KEY_PREFIX)) return acc; 
      acc[key] = JSON.parse(value); 
      return acc; 
    }, 
    {} 
  ); 
} 
 
/* ... */

To listen to changes in the neighbors’ positions, we’ll need a hook that subscribes to changes in localStorage. Let’s use useSyncExternalStore to do this. To learn more about this hook, check my post on syncing localStorage with React state.

First, we’ll need to update our setter storage util functions to fire storage events whenever they execute, indicating a data change that we can listen to:

utils/storage.ts
/* ... */
 
export function setCenter(windowId: string, position: Point): void {
  const key = createKey(windowId);
  const oldValue = localStorage.getItem(key); 
  const newValue = JSON.stringify(position); 

  localStorage.setItem(key, position);
  window.dispatchEvent( 
    new StorageEvent("storage", { key, oldValue, newValue }) 
  ); 
}
 
export function clearCenter(windowId: string): void {
  const key = createKey(windowId);
  const oldValue = localStorage.getItem(key); 

  localStorage.removeItem(key);
  window.dispatchEvent( 
    new StorageEvent("storage", { key, oldValue, newValue: null }) 
  ); 
}

Next, let’s write a subscriber function that listens to these storage events and triggers a callback:

utils/storage.ts
import { Point } from "./types";
 
const STORAGE_KEY_PREFIX = "@@window-communication";
 
export function createKey(windowId: string): string {
  return `${STORAGE_KEY_PREFIX}:${windowId}`;
}
 
export function subscribeToStorage(callback: () => void): () => void { 
  function listener(event: StorageEvent) { 
    if (event.key?.startsWith(STORAGE_KEY_PREFIX)) callback(); 
  } 
  window.addEventListener("storage", listener); 
  return () => window.removeEventListener("storage", listener); 
} 
 
/* ... */

Okay, I know what you’re thinking: why are we using storage events for a “shared memory” approach. Simply put: I did not want to long poll localStorage in a setInterval like how we did for the window position, given that there are better alternatives available. A pure shared memory approach would not have these “storage” events available.

Now that all our core utils are wired up to fire events, let’s write a new hook, useGetNeighbors, that will subscribe to localStorage via useSyncExternalStore to return the positions of all windows except for the current one:

hooks/shared-memory/use-get-neighbors.ts
import { useCallback, useRef, useSyncExternalStore } from "react";
import { useWindowId } from "../use-window-id";
import { Point } from "../../utils/types";
import { deepEquals } from "../../utils/deep-equals";
import {
  createKey,
  getWindowCenters,
  subscribeToStorage,
} from "../../utils/storage";
 
export function useGetNeighbors(): Record<string, Point> {
  const windowId = useWindowId();
  const storageKey = createKey(windowId);
 
  const cachedCenters = useRef<Record<string, Point>>({});
 
  const getCenters = useCallback(() => {
    const centers = getWindowCenters();
    if (deepEquals(cachedCenters.current, centers)) {
      return cachedCenters.current;
    }
    cachedCenters.current = centers;
    return centers;
  }, []);
 
  const allWindowCenters = useSyncExternalStore(subscribeToStorage, getCenters);
 
  const neighbors = Object.fromEntries(
    Object.entries(allWindowCenters).filter(([key]) => key !== storageKey)
  );
 
  return neighbors;
}

Some notes:

  • Our hook maintains a cache of the window centers. The getCenters function, which returns the snapshot for useSyncExternalStore, needs to return a stable object reference when the data hasn’t changed to avoid endless re-renders. This cache is what we’ll use to do that.
  • This want this hook to only returns neighbor positions, so we filter out the current window’s center from the list of neighbors.

If we wire this hook up to our main component and console.log the neighbors, we see how they update in real time when we move the windows around.

App.tsx
import { useMemo } from "react";
import { useGetNeighbors } from "./hooks/shared-memory/use-get-neighbors"; 
import { useBroadcastWindowCenter } from "./hooks/shared-memory/use-broadcast-window-center";
import { useWindowDimensions } from "./hooks/use-window-dimensions";
 
function App() {
  const { screenX, screenY, innerWidth, innerHeight } = useWindowDimensions();
 
  const currentCenter = useMemo(
    () => ({
      x: Math.floor(screenX + innerWidth / 2),
      y: Math.floor(screenY + innerHeight / 2),
    }),
    [screenX, screenY, innerWidth, innerHeight]
  );
 
  useBroadcastWindowCenter(currentCenter);
 
  const neighbors = useGetNeighbors(); 
 
  console.log(neighbors); 
 
  /* ... */
}

Drawing lines between windows

We’ve got all the pieces ready for drawing lines between windows. Let’s update our main component to draw lines from the current window’s center to all of its neighbors’ centers.

App.tsx
/* ... */
 
function App() {
 
  /* ... */
 
  const neighbors = useGetNeighbors();
 
  return (
    <div className="h-screen w-screen overflow-hidden">
      <div className="absolute top-1 left-1 p-2 text-white text-xs font-mono bg-black bg-opacity-50">
        Pos: ({currentCenter.x}, {currentCenter.y})
      </div>
      <svg className="h-full w-full">
        {Object.entries(neighbors).map(([id, neighborCenter]) => { 
          return ( 
            <line
              key={id}
              x1={currentCenter.x - screenX}
              y1={currentCenter.y - screenY}
              x2={5}
              y2={5}
              x2={neighborCenter.x - screenX}
              y2={neighborCenter.y - screenY}
              stroke="#4e46e5"
              strokeWidth="6"
              strokeLinecap="round"
            />
          ); 
        })}
      </svg>
    </div>
  );
}

Note that we also had to subtract screenX and screenY from the neighbor’s coordinates since they’re also in relation to the screen, rather than the window. For the moment of truth:

And it works! The lines follow the windows, and the graphic automatically updates in response to windows being opened or closed.

Abstractions

Our hook implementations have clean separation of concerns:

  • useBroadcastWindowCenter: Sends out information to other windows about the location of the current window’s center.
  • useGetNeighbors: Gets the positions of the other windows.

There abstractions don’t leak the fact that they use localStorage under the hood. Therefore, we should be able to rewrite their internals to use the message passing model, without changing their public interface at all! Let’s use this as a goal for implementing the message passing variant.

Message Passing Approach

For the message passing approach, we’re going to rewrite our hooks to rely on sending messages through the BroadcastChannel API. This API allows us to send and receive messages between windows of the same origin.

In the shared memory approach, the state of all windows lived in localStorage. In the message passing approach, each window will construct its own state of the network of windows by listening to messages from other windows. Moreover, each window participates by alerting other windows when its own position changes.

Broadcasting current position

We’re going to need some core utils for having a BroadcastChannel and sending messages. Let’s set up a BroadcastChannel and write some functions that send messages for the two events we’re interested in: updating the center position and clearing the center position. Let’s call these events CENTER_CHANGED and LEFT_NETWORK respectively:

  • CENTER_CHANGED: Sent whenever the window’s center position changes. Will include the ID of the window that changed, along with its new center point.
  • LEFT_NETWORK: Sent whenever a window closes. Will include the ID of the window that left.

The messages themselves will be sent via the channel’s postMessage method, which we’ll wrap with some utility functions: broadcastCenterChange and leaveNetwork.

utils/events.ts
import { Point } from "./types";
 
const CHANNEL_NAME = "window-center";
 
export const Events = {
  CENTER_CHANGED: "CENTER_CHANGED",
  LEFT_NETWORK: "LEFT",
} as const;
 
export type NetworkEventPayload =
  | {
      type: typeof Events.LEFT_NETWORK;
      windowId: string;
    }
  | {
      type: typeof Events.CENTER_CHANGED;
      windowId: string;
      center: Point;
    };
 
export const channel = new BroadcastChannel(CHANNEL_NAME);
 
export function leaveNetwork(windowId: string): void {
  channel.postMessage({ type: Events.LEFT_NETWORK, windowId });
}
 
export function broadcastCenterChange(windowId: string, center: Point): void {
  channel.postMessage({ type: Events.CENTER_CHANGED, windowId, center });
}

Let’s create a second useBroadcastWindowCenter hook that uses our channel to send messages to the other windows when its position changes or when the window closes.

hooks/message-passing/use-broadcast-window-center.ts
import { useEffect, useRef } from "react";
import { Point } from "../../utils/types";
import { useWindowId } from "../use-window-id";
import {
  channel,
  Events,
  leaveNetwork,
  broadcastCenterChange,
  NetworkEventPayload,
} from "../../utils/events";
 
export function useBroadcastWindowCenter(center: Point): void {
  const windowId = useWindowId();
 
  useEffect(() => {
    window.onbeforeunload = () => leaveNetwork(windowId);
    return () => {
      window.onbeforeunload = () => {};
      leaveNetwork(windowId);
    };
  }, [windowId]);
 
  useEffect(() => {
    broadcastCenterChange(windowId, center);
  }, [windowId, center]);
}

This hook is effectively identical to its shared memory counterpart!

Reading position of other windows

Now, we’ll need to listen to our broadcast channel for messages that other windows send. We’ll need to write a hook that registers an event listener to the channel and updates state based on what it receives. This is straightforward: we’ll upsert to our state whenever we receive a CENTER_CHANGED message, and delete from our state whenever we receive a LEFT_NETWORK message.

Let’s write a duplicate of our useGetNeighbors hook that does this:

hooks/message-passing/use-get-neighbors.ts
import { useEffect, useState } from "react";
import { Point } from "../../utils/types";
 
import { channel, Events, NetworkEventPayload } from "../../utils/events";
import { useWindowId } from "../use-window-id";
 
export function useGetNeighbors(): Record<string, Point> {
  const windowId = useWindowId();
  const [neighbors, setNeighbors] = useState<Map<string, Point>>(new Map());
 
  useEffect(() => {
    function handleMessage(event: MessageEvent<NetworkEventPayload>): void {
      // Ignore messages coming from the current window
      if (event.data.windowId === windowId) return;
 
      switch (event.data.type) {
        case Events.CENTER_CHANGED: {
          const { windowId: receivedId, center: receivedCenter } = event.data;
          setNeighbors((val) => new Map(val).set(receivedId, receivedCenter));
          break;
        }
 
        case Events.LEFT_NETWORK:
          setNeighbors((val) => {
            const newVal = new Map(val);
            newVal.delete(event.data.windowId);
            return newVal;
          });
          break;
        default:
          break;
      }
    }
 
    channel.addEventListener("message", handleMessage);
 
    return () => {
      channel.removeEventListener("message", handleMessage);
    };
  }, [windowId, setNeighbors]);
 
  return Object.fromEntries(neighbors);
}

In the shared memory version of this hook, we had to manually filter the results to exclude the current window. In this version, we can just ignore messages that come from the current window.

Looks like we’re done! Since neither of our hooks’ interfaces changed, all we have to do in the main component is swap the hook imports:

App.tsx
import { useMemo } from "react";
import { useGetNeighbors } from "./hooks/shared-memory/use-get-neighbors"; 
import { useBroadcastWindowCenter } from "./hooks/shared-memory/use-broadcast-window-center"; 
import { useGetNeighbors } from "./hooks/message-passing/use-get-neighbors"; 
import { useBroadcastWindowCenter } from "./hooks/message-passing/use-broadcast-window-center"; 
import { useWindowDimensions } from "./hooks/use-window-dimensions";
import { Point } from "./utils/types";
 
function App() {
  /* ... */
}
 
export default App;

Let’s spin up the windows and see what happens:

Wait, why isn’t it working when the new window joins? Simply put, the new window is joining the conversation with the other windows too late. The first window already broadcasted its position, but the new window wasn’t around at the time to receive it. As a result, it can’t draw a line towards its neighbor because it doesn’t know where it is.

Handling newcomers

We can fix this by adding one more event type: JOIN_NETWORK. When a new window joins, it sends out a message with its ID and center. The other windows welcome it by immediately responding back with their respective center positions. This will catch the new window up to speed and allow it to construct state, while allowing existing windows to know where the new member is.

Note: we could fix our protocol without a JOIN_NETWORK event. Whenever a window receives a CENTER_CHANGED event, we could check if we’ve has seen the window ID before. If not, it could send a CENTER_CHANGED event back to the new window. I decided to go with the JOIN_NETWORK since it reads more clearly.

Let’s start by updating our core utils to include this new event type:

utils/events.ts
import { Point } from "./types";
 
const CHANNEL_NAME = "window-center";
 
export const Events = {
  JOINED_NETWORK: "JOINED", 
  CENTER_CHANGED: "CENTER_CHANGED",
  LEFT_NETWORK: "LEFT",
} as const;
 
export type NetworkEventPayload =
  | { 
      type: typeof Events.JOINED_NETWORK; 
      windowId: string; 
      center: Point; 
    } 
  | {
      type: typeof Events.LEFT_NETWORK;
      windowId: string;
    }
  | {
      type: typeof Events.CENTER_CHANGED;
      windowId: string;
      center: Point;
    };
 
export const channel = new BroadcastChannel(CHANNEL_NAME);
 
export function joinNetwork(windowId: string, center: Point): void { 
  channel.postMessage({ type: Events.JOINED_NETWORK, windowId, center }); 
} 
 
/* ... */

Now, let’s update our useBroadcastWindowCenter hook to send a JOIN_NETWORK message for the very first time it sends its position. Every time after that, it can just send a CENTER_CHANGED message:

hooks/message-passing/use-broadcast-window-center.ts
import { useEffect, useRef } from "react";
import { Point } from "../../utils/types";
import { useWindowId } from "../use-window-id";
import {
  channel,
  Events,
  joinNetwork, 
  leaveNetwork,
  broadcastCenterChange,
  NetworkEventPayload,
} from "../../utils/events";
 
export function useBroadcastWindowCenter(center: Point): void {
  const windowId = useWindowId();
 
  /* ... */
 
  const hasJoinedNetwork = useRef(false); 
  useEffect(() => {
    if (!hasJoinedNetwork.current) { 
      joinNetwork(windowId, center); 
      hasJoinedNetwork.current = true; 
      return
    } 
    broadcastCenterChange(windowId, center);
  }, [windowId, center]);
}

Let’s also add the bounceback part of this protocol to the useBroadcastWindowCenter hook. Whenever a window receives a JOIN_NETWORK message, it should respond with a CENTER_CHANGED message:

hooks/message-passing/use-broadcast-window-center.ts
import { useEffect, useRef } from "react";
import { Point } from "../../utils/types";
import { useWindowId } from "../use-window-id";
import {
  channel,
  Events,
  joinNetwork,
  leaveNetwork,
  broadcastCenterChange,
  NetworkEventPayload,
} from "../../utils/events";
 
export function useBroadcastWindowCenter(center: Point): void {
 
  /* ... */
 
  useEffect(() => { 
    function handleMessage(event: MessageEvent<NetworkEventPayload>): void { 
      if (event.data.windowId === windowId) return; 
      if (event.data.type === Events.JOINED_NETWORK) { 
        broadcastCenterChange(windowId, center); 
      } 
    } 
    channel.addEventListener("message", handleMessage); 
    return () => { 
      channel.removeEventListener("message", handleMessage); 
    }; 
  }, [windowId, center]); 
}

Lastly, let’s update our useGetNeighbors hook to listen for JOIN_NETWORK. If it receives this message, it should update state exactly as if it received a CENTER_CHANGED message.

hooks/message-passing/use-get-neighbors.ts
/* ... */
 
export function useGetNeighbors(): Record<string, Point> {
  const windowId = useWindowId();
  const [neighbors, setNeighbors] = useState<Map<string, Point>>(new Map());
 
  useEffect(() => {
    function handleMessage(event: MessageEvent<NetworkEventPayload>): void {
      if (event.data.windowId === windowId) return;
 
      switch (event.data.type) {
        case Events.JOINED_NETWORK: 
        case Events.CENTER_CHANGED: {
          const { windowId: receivedId, center: receivedCenter } = event.data;
          setNeighbors((val) => new Map(val).set(receivedId, receivedCenter));
          break;
        }
 
        /* ... */
}

And that’s it! Our message passing approach handles newcomers. Let’s test it out again:

And it works like a charm!

Graphics Polish

During testing, we notice that some portions of the lines are “missing” if they travel through a window that isn’t the start or end of said line:

Missing lines

To fix this (and make the demo really come to life), we can have each window draw lines between all pairs of neighbors, rather than just the lines from itself to its neighbors.

We can do this by generating a list of all unique neighbor pairs, and drawing the lines between said pairs. Since each window has complete knowledge of all neighbors and their positions, the only updates we need to make are in the main component:

App.tsx
import { useMemo } from "react";
import { useGetNeighbors } from "./hooks/storage/use-get-neighbors";
import { useBroadcastWindowCenter } from "./hooks/storage/use-broadcast-window-center";
import { useWindowDimensions } from "./hooks/use-window-dimensions";
import { Point } from "./utils/types";
 
function getNeighborPairs(neighbors: Record<string, Point>): [string, string][] { 
  const pairs: [string, string][] = []; 
  const neighborIds = Object.keys(neighbors); 
  for (let i = 0; i < neighborIds.length; i++) { 
    for (let j = i + 1; j < neighborIds.length; j++) { 
      pairs.push([neighborIds[i], neighborIds[j]]); 
    } 
  } 
  return pairs; 
} 
 
function App() {
  const { screenX, screenY, innerWidth, innerHeight } = useWindowDimensions();
 
  const currentCenter = useMemo(
    () => ({
      x: Math.floor(screenX + innerWidth / 2),
      y: Math.floor(screenY + innerHeight / 2),
    }),
    [screenX, screenY, innerWidth, innerHeight]
  );
 
  useBroadcastWindowCenter(currentCenter);
 
  const neighbors = useGetNeighbors();
 
  const neighborPairs = useMemo(() => getNeighborPairs(neighbors), [neighbors]); 
 
  return (
    <div className="h-screen w-screen overflow-hidden">
      <div className="absolute top-1 left-1 p-2 text-white text-xs font-mono bg-black bg-opacity-50">
        Pos: ({currentCenter.x}, {currentCenter.y})
      </div>
      <svg className="h-full w-full">
        {Object.entries(neighbors).map(([id, neighborCenter]) => {
          return (
            <line
              key={id}
              x1={currentCenter.x - screenX}
              y1={currentCenter.y - screenY}
              x2={neighborCenter.x - screenX}
              y2={neighborCenter.y - screenY}
              stroke="#4e46e5"
              strokeWidth="6"
              strokeLinecap="round"
            />
          );
        })}
 
        { /* Draw lines between all neighbor pairs */ }
        {neighborPairs.map(([neighborA, neighborB]) => { 
          return ( 
            <line
              key={`${neighborA}-${neighborB}`}
              x1={neighbors[neighborA].x - screenX}
              y1={neighbors[neighborA].y - screenY}
              x2={neighbors[neighborB].x - screenX}
              y2={neighbors[neighborB].y - screenY}
              stroke="#4e46e5"
              strokeWidth="6"
              strokeLinecap="round"
            /> 
          ); 
        })}
      </svg>
    </div>
  );
}
 
export default App;

Now, lines that go across windows that aren’t defining members of the line are still drawn, making the window graph feel complete no matter how the windows are arranged.

Conclusion

We’ve successfully implemented a cross-window communication system using two completely different IPC models: shared memory and message passing. We also did it in such a way that the internals of the model are hidden away.

These two methods aren’t the only way to communicate client-side across windows. You can also use the window.postMessage API, which allows you to send messages between any windows (even if they’re different origins 😱) as long as you have a reference to the window object. We make use of this protocol at Makeswift to enable realtime editing of sites. I also wrote a Chrome extension, Postmaster, as a comprehensive logging tool for window.postMessage messages (see the source here). If you ever explore postMessage, check it out!

If you’re interested in the source for this project, you can find it on Github at: https://github.com/arvinpoddar/cross-window-communication