Skip to content

Conversation

@akirk
Copy link
Member

@akirk akirk commented Jan 22, 2026

Based on #3162.

Motivation for the change, related issues

When users open the same persistent site in multiple browser tabs, OPFS (Origin Private File System) conflicts can occur because each tab tries to access the same storage. This PR adds a coordination system so only one tab owns the PHP worker while other tabs operate in "dependent mode".

Screenshots

Screenshot 2026-01-22 at 05 02 39 Screenshot 2026-01-22 at 05 02 43 Screenshot 2026-01-22 at 05 02 54 Screenshot 2026-01-22 at 05 03 03 Screenshot 2026-01-22 at 05 03 08 Screenshot 2026-01-22 at 05 03 14

Implementation details

  • Tab Coordinator (tab-coordinator.ts): Uses BroadcastChannel API for cross-tab communication with ping/pong discovery, takeover requests, and backup delegation
  • Boot Logic (boot-site-client.ts): Checks for existing tabs before booting; yields to fresh main tabs or becomes dependent if one exists
  • Dependent Mode: Tabs without their own worker use the main tab's service worker URL and delegate operations like backup
  • UI Indicators:
    • WorkerStatusIndicator in address bar shows "Main" or "Dependent" badge
    • TabInfoWindow in overlay shows WordPress boot time and connected tabs
  • Edge Cases:
    • Main tab reload: Dependent tabs report isDependentMode: true in pong, so reloading main correctly re-establishes as main
    • Backup from dependent: Cross-tab messaging delegates backup request to main tab
    • Main tab closes: Dependent tabs show "Reload required" indicator

Testing Instructions (or ideally a Blueprint)

  1. Open a persistent site in Tab 1 → should boot normally
  2. Open the same site in Tab 2 → should show "Dependent" badge, Tab 1 shows "Main"
  3. Click badge to open overlay → shows boot time and other tabs count
  4. In Tab 2 overlay, click Backup → download triggers (handled by main tab)
  5. Reload Tab 1 → should re-establish as main (not become dependent)
  6. Close Tab 1 → Tab 2 shows "Reload required"

@akirk akirk changed the title [Website] Personal Playground: Add multi-tab coordination [website] Personal Playground: Add multi-tab coordination Jan 22, 2026
@akirk akirk force-pushed the persistent/multi-tab-coordination branch from a75d636 to add3bab Compare January 22, 2026 14:41
@akirk akirk force-pushed the persistent/backup-reminder branch 2 times, most recently from 1555eba to 4da5ad5 Compare January 23, 2026 05:03
@akirk akirk force-pushed the persistent/multi-tab-coordination branch from add3bab to 7cdbb30 Compare January 23, 2026 05:03
@akirk akirk changed the title [website] Personal Playground: Add multi-tab coordination [personal-wp] Add multi-tab coordination Jan 23, 2026
@akirk akirk force-pushed the persistent/backup-reminder branch from 4da5ad5 to 817712c Compare January 23, 2026 12:07
@akirk akirk force-pushed the persistent/multi-tab-coordination branch from 7cdbb30 to c1b2abd Compare January 23, 2026 12:07
@akirk akirk force-pushed the persistent/backup-reminder branch from 817712c to 7158776 Compare January 23, 2026 13:17
@akirk akirk force-pushed the persistent/multi-tab-coordination branch from c1b2abd to c94e2d5 Compare January 23, 2026 13:17
const remoteUrl = getRemoteUrl();
const scopedSiteUrl = `/scope:${encodeURIComponent(site.slug)}/`;

const dependentModeClient = {
Copy link
Collaborator

@adamziel adamziel Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods seem like a less capable version of the original client methods, e.g. goTo()) covers for a few relevant corner-cases. I understand we need this because there's not a playgroundClient we could use to transform playground.pathToInternalUrl()? Could we somehow declare const playground = mainPlaygroundRunningInAnotherTab? e.g. using broadcast channels for communication? We may need a custom Comlink transport but could be doable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admittedly I am not sure I understand this in-and-out enough but the way I understand it is that the PlaygroundClient basically fulfills navigational tasks for the iframe communication and php worker stuff. Could we refactor out those navigational tasks so that our dependentModeClient could have those and all php worker things would remain in the PlaygroundClient.

*
* @param tabs - List of tabs to check
*/
export function requestStaleTabsShutdown(tabs: TabInfo[]): void {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Member Author

@akirk akirk Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from the worry that people will have super long running tabs. AFAIK we don't have a mechanism to check with the server if there is a new version of playground. This is mostly to avoid people sitting on stale and outdated tabs for months.

* @param targetTabId - The tab ID to shut down
* @param reason - Why the tab should shut down
*/
export function requestTabShutdown(
Copy link
Collaborator

@adamziel adamziel Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only called once, in the function below. Can we either inline it or not export it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove the export.

const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const PING_TIMEOUT_MS = 150;

let channel: BroadcastChannel | null = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to name it: these are local variables that are not managed by redux even though this file lives in state/redux. It seems fine. I don't expect we'll need to run multiple redux stores that run independent tab coordination.

let siteResetCallback: (() => void) | null = null;
let beforeUnloadHandler: (() => void) | null = null;

// Clean up on Vite HMR to prevent duplicate listeners
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is weird, is there a general way of expressing this that doesn't depend on vite or encode vite-specific APIs?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a remainder of debugging some weirdnes, I think I can remove it.

Comment on lines +108 to +136
*/
export function useRemoteBackup() {
const clientInfo = usePlaygroundClientInfo();
const activeSite = useActiveSite();
const [isRequestingBackup, setIsRequestingBackup] = useState(false);

const isDependentMode = clientInfo?.isDependentMode ?? false;

const requestBackup = useCallback(async (): Promise<boolean> => {
if (!activeSite || !isDependentMode || isRequestingBackup) {
return false;
}

setIsRequestingBackup(true);
try {
const success = await requestRemoteBackup(activeSite.slug);
return success;
} finally {
setIsRequestingBackup(false);
}
}, [activeSite, isDependentMode, isRequestingBackup]);

return {
requestBackup,
isRequestingBackup,
isDependentMode,
canRequestBackup: isDependentMode && !!activeSite,
};
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structure seems pretty generic, maybe later on it will make sense to have a generic cross-tab client and a useCrossTabRPC( "backup" ) call? Let's watch how this evolves. Nothing blocking or actionable in this comment.

// This callback is called when another tab requests to take over as main
// We switch to dependent mode without showing an error
const remoteUrl = getRemoteUrl();
const scopedSiteUrl = `/scope:${encodeURIComponent(site.slug)}/`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a helper for this in

export function setURLScope(url: URL | string, scope: string | null): URL {
. encodeURIComponent may be a useful addition

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, our scope is actually hardcoded to default.

Copy link
Collaborator

@adamziel adamziel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments, none of which need to be addressed before shipping this. Feel free to merge anytime.

@akirk akirk force-pushed the persistent/backup-reminder branch from 7158776 to b72b187 Compare January 23, 2026 16:09
@akirk akirk force-pushed the persistent/multi-tab-coordination branch 3 times, most recently from 48a1290 to 55a7f29 Compare January 26, 2026 09:27
Base automatically changed from persistent/backup-reminder to trunk January 27, 2026 12:05
akirk added 2 commits January 27, 2026 13:06
- Remove export from requestTabShutdown (only used internally)
- Remove Vite HMR cleanup code (dev-only, accept occasional HMR quirks)
@akirk akirk force-pushed the persistent/multi-tab-coordination branch from 55a7f29 to 45accb9 Compare January 27, 2026 12:07
@akirk akirk merged commit 989ddcf into trunk Jan 27, 2026
35 checks passed
@akirk akirk deleted the persistent/multi-tab-coordination branch January 27, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants