Synchronizing Application State Across Browser Frames
Squarespace’s product offerings have grown over the course of many years, first with a website builder, then an ecommerce platform, then domains. In recent years, that list has grown to include an email campaigns composer, an appointment scheduler, a storytelling toolkit, bio sites for social media… and there’s more in the pipeline.
Inevitably, these applications need to share some common pieces of state: things like the name of the customer’s business, the currently logged-in user, the set of products the user is currently trialling or subscribed to, and so on. Over time, as we added more products, and more functionality to existing products, we used various ad-hoc mechanisms for keeping track of this state across all the places it was accessed or updated.
One thing that compounded this problem was the fact that several of our products are architected such that they run in an iframe, not in the main React component tree. This confers many benefits: isolation of errors, more autonomous deployments, less coupling between codebases, and more. In effect, mounting apps in an iframe works similarly to a microfrontend architecture, except that it doesn’t require a platform-wide redesign.
However, iframes make the job of synchronizing that shared state all the more difficult, and incentivize the use of yet more ad-hoc solutions. Sometimes we would pass a value down to an iframed application via a query parameter on its src
attribute. Sometimes an iframe would pass a value back up to the main window via a call to window.top.postMessage()
. And so on.
These solutions worked well for a short while, but inevitably they started to get in the way: they were hard to keep track of, easy to break, and scattered over the codebase. We needed a more general solution – one which worked regardless of whether state was being shared within the same frame or between two entirely separate JavaScript contexts. Enter Universal State.
Universal State is an internal library we developed to serve as a single source of truth for state that needs to be shared across applications. It uses an interface similar to the Redux state management library or React’s useReducer hook: components receive state in the form of a plain JavaScript value, and modify state by dispatching actions, which are also plain JavaScript objects.
When imported by the top window, Universal State creates a singleton store to which consumer components connect – simple and straightforward – but when imported in an iframe application, it sets up a “proxy” store, which communicates with the store in the top window via a postMessage
bridge. State updates flow from the “real” store to the proxy store, and dispatched actions flow back.
We use Google’s Comlink library to abstract away all the postMessage
and MessageChannel
plumbing, but a nice thing about using a reducer-style interface is that it doesn’t couple us to the library too much – if we decide we need to roll our own protocol, the fact that we’re already sending plain, serializable objects back and forth will make that job much easier.
The last thing we wanted to do was disrupt the lives of teams who were perfectly happy with their current solution, or force a big, coordinated migration to something untested. Universal State, therefore, doesn’t concern itself with state that’s specific to an application – only what’s shared between them.
Rollout
Instead of a hard switch, Universal State is being adopted incrementally: we move data to it a piece at a time, managing the rollout with feature flags until we’re satisfied that it works. Unfortunately, despite our precautions, there was one bug we didn’t fix until after we rolled out Universal State on one of the iframe applications.
With a conventional state management library like Redux, a component never needs to worry about whether the store exists – the store is guaranteed to have been created by the time the component mounts. However, when that store is on the other side of a postMessage bridge, there’s a small delay on page load while the iframe waits for the top window to establish communication (which it does by calling a function that the iframe exposes via Comlink). If the iframe application tries to dispatch an action before the bridge is up, that action has nowhere to go.
We’d anticipated this race condition when we designed the library, and come up with some strategies for preventing it, but – as is always a risk with a complex cross-team integration – it fell through the cracks. As a result, the library threw an error, the component tree failed to render, and the application was unavailable in production for 10 minutes.
Fortunately, the fix was easy: we queue up pending actions on the iframe side and dispatch them once the bridge is established. A blame-free retrospective also gave us some follow-up action items: improve our automated smoke test coverage and document our library’s error conditions more explicitly.
Future
As new pieces of state need to be shared across apps, lifting those pieces up to Universal State is a much more attractive proposition than adding another query parameter or hand-rolling another postMessage
payload. Over time, we can also establish organizational best practices around how to structure and access that data, optimize updates, and handle common concerns like data fetching and deduplication.
Furthermore, now that we have a uniform state-sharing interface across both iframe and top-window apps, we’re free to use different architectural approaches where appropriate. An iframe app could instead be mounted in the top window, or vice versa, with no changes to its state-related code.
We’re now set up much better for the future – as our products become more numerous and more integrated, they’ll need to share more and more data, and we finally have a place to put it.