urql icon indicating copy to clipboard operation
urql copied to clipboard

RFC: Cross-Tab Synchronization Exchange

Open kitten opened this issue 3 years ago • 10 comments

Summary

Especially with Graphcache we've discussed cases where a sufficiently complex app may want to synchronize what's happening across apps. As part of this one may have multiple tabs of a Graphcache application opened.

We can make the assumption that it doesn't matter whether the cache itself is 100% in sync, but we hypothesize that two tabs of Graphcache can already coexist (Needs Testing)

A cross-tab synchronization exchange can therefore focus on distributing results across all apps.

Proposed Solution

Create an exchange that deduplicates fetching results and distributes results to other tabs, maybe using the BroadcastChannel API.

  • Multiple exchanges of cross-tabs sync should be aware of one another
  • Only one tab (the latest active one?) should execute an operation
  • The results from that tab should be distributed to other tabs
  • Check whether Graphcache's persistence conflicts when multiple tabs are open

Related Conversations

  • This would enable Electron usage (Caution! This is a very old issue) https://github.com/FormidableLabs/urql/issues/42
  • Request for this feature: https://github.com/FormidableLabs/urql/discussions/996
  • Another request for this feature: https://github.com/FormidableLabs/urql/discussions/1012#discussioncomment-87636

kitten avatar Oct 14 '20 14:10 kitten

Here's a nice video about the different ways to synchronize data across documents: https://www.youtube.com/watch?v=9UNwHmagedE

Just posting it in case it can help to decide on what API to use. They do speak about the BroadcastChannel API which looks great but not supported by Safari.

tatchi avatar Oct 18 '20 11:10 tatchi

A use case to consider is how this would work with the subscription exchange and with subscriptions that tabs may have open:

  • Does this deduplicate subscriptions? Or just have all subscriptions running but deduplicate results?
  • Can subscriptions safely be reopened in another tab after the tab containing the subscription closes?

Kingdutch avatar Dec 16 '20 10:12 Kingdutch

I've been wondering if we could handle this a bit more naive, in the sense that we could do something like combining the refocusExchange and the persisted data to achieve this goal.

Let's say a user has a list of todos on tab 1, and the same window open on tab2. The user mutates a todo on tab 1, this will indicate loading, mutate against the server and come up with a result. This will be a deferred write to our storageAdapter. We could place the storageAdapter in some "lock-state" while this response is pending.

When the user switched to tab2, the refocusExchange will trigger and our cache should trigger the promise to readFromStorage, this means that the queries on-screen will be refetched. This hits the cache which will in-turn buffer these queries since we have a pending readFromStorage, when the cache sees that it's in lock-state it should poll for the lock to be removed, when it's removed it can rehydrate the cache and respond to the in-flight queries.

The concern I have here is that currently we don't use our optimistic results in storage, so this could mean that we inherit the pending mutations from the storage which could possibly introduce us dispatching them twice. This should be a case to take in account.

JoviDeCroock avatar Dec 28 '20 13:12 JoviDeCroock

I think that'd require us to make the assumption that only one tab is "active" at a time, which isn't necessarily the case with multiple windows, background tasks, timers, etc 😅

kitten avatar Dec 28 '20 14:12 kitten

What's the progress on this one? Did anybody manage to get something like this to work?

TuringJest avatar Apr 22 '22 11:04 TuringJest

Yeah. This is really needed.

frederikhors avatar Apr 22 '22 11:04 frederikhors

@kitten do you actually need a cross-tab synchronization exchange, considering that "data stored in IndexedDB is available to all tabs from within the same origin" (ref)? Switching to a tab could refresh its state based on a pull from IndexedDB. If a background push to all tabs would be too complex.

redbar0n avatar Dec 02 '22 15:12 redbar0n

This is how I implemented syncExchange with the help of IndexedDB. It's probably not super efficient, but good enough for my needs:

import { Exchange, OperationResult } from 'urql';
import { makeSubject, merge, pipe, tap } from 'wonka';

function pageVisible(callback: (visible: boolean) => void) {
  if (typeof window === 'undefined') {
    return () => undefined;
  }

  const focusHandler = () => callback(true);
  // Blur handler gives us a few false positives, but we can live with that
  const blurHandler = () => callback(false);
  const visibilityChangeHandler = () => {
    callback(document.visibilityState === 'visible');
  };

  window.addEventListener('focus', focusHandler, false);
  window.addEventListener('blur', blurHandler, false);
  window.addEventListener('visibilitychange', visibilityChangeHandler, false);

  // Returns unsubscribe
  return () => {
    window.removeEventListener('focus', focusHandler);
    window.removeEventListener('blur', blurHandler);
    window.removeEventListener('visibilitychange', visibilityChangeHandler);
  };
}

export function syncExchange(): Exchange {
  let leader = true;
  pageVisible((visible) => (leader = visible));

  return ({ forward }) =>
    (operations$) => {
      if (typeof window === 'undefined') {
        return forward(operations$);
      }

      const { source, next } = makeSubject<OperationResult>();
      const channel = new BroadcastChannel('syncExchange');

      channel.addEventListener('message', (event) => {
        if (leader) {
          return;
        }

        next(event.data as OperationResult);
      });

      const processOutgoingOperation = (operation: OperationResult) => {
        if (!leader) {
          return;
        }

        // Right now we're forwarding everything, but since it's only on already
        // handled graphql operations, it shouldn't matter for our use case
        channel.postMessage(operation);
      };

      return pipe(
        merge([forward(operations$), source]),
        tap(processOutgoingOperation)
      );
    };
}

Zn4rK avatar Dec 02 '22 15:12 Zn4rK

They do speak about the BroadcastChannel API which looks great but not supported by Safari. [as of Oct 18, 2020]

BroadcastChannel has full support in Safari now, as of Mar 15 2022.

redbar0n avatar Dec 04 '22 09:12 redbar0n