Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Cross-Tab Synchronization Exchange #1062

Open
kitten opened this issue Oct 14, 2020 · 9 comments
Open

RFC: Cross-Tab Synchronization Exchange #1062

kitten opened this issue Oct 14, 2020 · 9 comments
Labels
future 🔮 An enhancement or feature proposal that will be addressed after the next release

Comments

@kitten
Copy link
Member

kitten commented Oct 14, 2020

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

@kitten kitten added the future 🔮 An enhancement or feature proposal that will be addressed after the next release label Oct 14, 2020
@tatchi
Copy link
Contributor

tatchi commented Oct 18, 2020

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
Copy link
Contributor

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?

@JoviDeCroock
Copy link
Collaborator

JoviDeCroock commented Dec 28, 2020

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
Copy link
Member Author

kitten commented Dec 28, 2020

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
Copy link

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

@frederikhors
Copy link
Contributor

Yeah. This is really needed.

@redbar0n
Copy link

redbar0n commented Dec 2, 2022

@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
Copy link

Zn4rK commented Dec 2, 2022

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
Copy link

redbar0n commented Dec 4, 2022

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
future 🔮 An enhancement or feature proposal that will be addressed after the next release
Projects
None yet
Development

No branches or pull requests

8 participants