Tally UI

Live Tab

Exactly one live tab per store, with the take-over and the parked screen

ADR-061 decides that there is exactly one live tab per store, and that a second tab never opens storage. startLiveTab from @tallyui/database coordinates this over a Web Lock and a BroadcastChannel, and LiveTabScreen from @tallyui/components renders what a tab shows once it is no longer the live one.

On platforms without Web Locks (React Native, Node, tests), a tab is simply live: native and Electron have one window per store.

The protocol

  • A new tab asks the current live tab to hand over.
  • The live tab closes its storage, releases the lock and parks on a "POS is open in another tab" screen.
  • The live tab may delay the hand-over only while a sale or a write is in flight, and only up to a limit.
  • If nothing answers, the new tab tells the user to close the other tab.
  • Reload is the recovery.

startLiveTab returns a state$ observable of 'acquiring' | 'live' | 'parked' | 'blocked', a takeOver() for the parked screen's "Use here" button, and a stop() for sign-out or unmount.

After a back or forward navigation restores the page from the browser's cache, the coordinator re-acquires by itself (acquiring, then live or blocked), so the app needs no extra handling. onPark already ran (best-effort) when the page was cached, so this live is a fresh open, not a continuation: the app re-creates its database exactly as it does on the wiring example's first live.

Wiring it up

import { startLiveTab } from '@tallyui/database';
import { LiveTabScreen } from '@tallyui/components';

// How long parking waits for the databases to close. 3 s fits the new tab's
// 13 s "blocked" deadline after the longest deferral (10 s); a healthy close
// takes milliseconds.
const PARK_CLOSE_LIMIT_MS = 3_000;

const liveTab = startLiveTab({
  scope: storeId,
  onPark: async () => {
    stockReconcile.stop();
    // 1. Close with the worker still alive. RxDB's remote close() asks the
    //    worker to close and waits for its reply, and frees the database name
    //    only when the close finishes.
    const closed = await Promise.race([
      Promise.all(databases.map((db) => db.close())).then(() => true),
      new Promise<false>((resolve) => setTimeout(() => resolve(false), PARK_CLOSE_LIMIT_MS)),
    ]);
    // 2. Then terminate the storage's worker, which releases the opfs-sahpool
    //    access handles and clears rxdb's cached channel for a fresh open.
    storage.terminate();
    // 3. A close that timed out may have left storage inconsistent: treat it
    //    as stalled, and prompt a reload.
    if (!closed) showStorageReloadPrompt();
  },
  // The hand-over waits while this returns true: a sale is mid-checkout, or
  // the outbox is sending an order.
  isBusy: () => outbox.state$.value.sending || checkout.step !== 'idle',
});

function App() {
  const state = useObservable(liveTab.state$, 'acquiring');

  return (
    <>
      <LiveTabScreen state={state} onUseHere={liveTab.takeOver} onReload={() => location.reload()} />
      {state === 'acquiring' && <Loading />}
      {state === 'live' && <POS />}
    </>
  );
}

The POS, and the database it opens, renders only for 'live': an acquiring tab must not open storage while another tab still holds it, and the app drops the POS whenever the state leaves 'live' for any reason, including pagehide.

onPark must finish closing the databases and stop its storage worker before it returns. The lock is released only after it returns, so the new tab never opens storage while the old tab still holds it.

Park in this order: close, then terminate (ADR-061, amendment 1).

  • Close every database with the worker alive. RxDB's remote close() sends close to the worker and waits for its reply. It frees the database name only when the close finishes.
  • Why terminating first fails. If you terminate first, every close hangs, and the name stays taken, so the next open fails with RxDB error DB8.
  • Bound the wait with a fixed limit.
  • Then terminate the worker with storage.terminate(). Terminating releases the opfs-sahpool access handles. It also clears rxdb's cached channel for that worker, so a fresh open works: a raw worker.terminate() leaves the next storage reusing the dead worker's channel, and every call hangs.
  • On timeout, terminate anyway, treat storage as stalled, and prompt a reload.

One storage for all stores. The app creates one storage with getRxStorageSQLiteWasm and opens every store's database on it. That storage runs in mode 'one': one worker, which holds the exclusive opfs-sahpool pool. databases in the example means every database opened on that storage. A second storage would start a second worker, and that worker cannot open the pool.

Call liveTab.stop() on sign-out or unmount, so the lock and the channel are released.

Translating the screen

Every string on LiveTabScreen is a prop with an English default, so an app can translate them: parkedTitle, parkedBody, useHereLabel, blockedBody and reloadLabel.

<LiveTabScreen
  state={state}
  onUseHere={liveTab.takeOver}
  onReload={() => location.reload()}
  parkedTitle="POS ist in einem anderen Tab geöffnet"
/>