urql-graphql / urql

The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
https://urql.dev/goto/docs
MIT License
8.55k stars 443 forks source link

RFC: Cross-Tab Synchronization Exchange #1062

Open kitten opened 3 years ago

kitten commented 3 years ago

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.

Related Conversations

tatchi commented 3 years ago

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.

Kingdutch commented 3 years ago

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

JoviDeCroock commented 3 years ago

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.

kitten commented 3 years ago

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 😅

TuringJest commented 2 years ago

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

frederikhors commented 2 years ago

Yeah. This is really needed.

redbar0n commented 1 year ago

@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.

Zn4rK commented 1 year ago

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)
      );
    };
}
redbar0n commented 1 year ago

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.

leggomuhgreggo commented 1 month ago

Last comment here was a couple years back — curious what the latest thinking is. 🙏