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.
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:
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:
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.
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.
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:
- Each window maintains a separate key in
localStorage
where it stores its center point. - 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
:
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:
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
.
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.
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:
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.
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:
/* ... */
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:
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:
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 foruseSyncExternalStore
, 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.
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.
/* ... */
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
.
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.
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:
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:
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:
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:
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:
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.
/* ... */
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:
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:
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