Safely handle multiple tabs open #52
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#52
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The app currently has no awareness of multiple browser tabs. Since IndexedDB is shared across tabs, concurrent writes from two tabs could corrupt patch history or interleave edits unpredictably. We'll implement a single-active-tab lock using
BroadcastChannel: the most recently loaded tab claims "active" status, while all other tabs receive a full-screen blocking overlay with a "Use here" button that reloads the page and re-claims active status. Keyboard shortcuts are also blocked while inactive by threading anisActive()check into the existing keydown handler.Relevant Context
src/index.ts:GTFSEditorconstructor +init()— whereTabLockControllerwill be instantiated and initialized (early ininit(), before other modules start accepting edits)src/modules/keyboard-shortcuts.ts:217— singledocument.addEventListener('keydown', ...)inbindEventListeners(); addif (!this.tabLock?.isActive()) return;at the topsrc/modules/notification-system.ts— toast-style, not a blocking modal; not suitable for the inactive overlay (no backdrop, always has a dismiss button)BroadcastChannelAPI — built-in, no deps, ~97% support, messages do NOT go to the sender tabpointer-events; keyboard shortcuts need a separate guardclaimat the same time both hear each other and both deactivate. Fix: includeclaimedAt: Date.now()in the claim; the later claimant wins (higher timestamp). If equal, highertabIdwins. Each tab only deactivates if the incoming claim is "newer" than its own.Phase 1: TabLockController + Blocking Overlay
Goal: Implement cross-tab coordination and the blocking UI. This is a self-contained new module wired into
index.ts.Create
src/modules/tab-lock.tswithTabLockControllerclass:private tabId: string(fromcrypto.randomUUID()),private claimedAt: number(fromDate.now()),private active = true,private channel: BroadcastChannel,private overlay: HTMLElement | null = nullinit()method:new BroadcastChannel('gtfs-zone-tab-lock')this.channel.onmessage = (e) => this.handleMessage(e.data){ type: 'claim', tabId: this.tabId, claimedAt: this.claimedAt }window.addEventListener('beforeunload', () => this.channel.close())private handleMessage(msg: { type: string; tabId: string; claimedAt: number }):msg.type !== 'claim'returnmsg.claimedAt > this.claimedAt || (msg.claimedAt === this.claimedAt && msg.tabId > this.tabId)this.active = false, callthis.showOverlay()private showOverlay():this.overlayalready exists, return (idempotent)divappended todocument.body:overlay.querySelector('#tab-lock-use-here')?.addEventListener('click', () => window.location.reload())console.log('[TabLock] Tab deactivated — another tab claimed active status')isActive(): boolean { return this.active; }In
src/index.ts:TabLockControllerfrom./modules/tab-lock.jspublic tabLock: TabLockControlleras a class fieldthis.tabLock = new TabLockController()init(), as the first async step before any other module init:this.tabLock.init()tabLock: this.tabLockto the object passed toKeyboardShortcutsconstructor (or pass separately — see Phase 2)Gotchas:
z-[200]is needed — the notification container isz-50, the map controls are likelyz-10;200safely winsBroadcastChannelfires async, so it's impossible to block init before the channel fires. The overlay will appear after the rest of the app has initialized. This is fine — it deactivates on the next event loop tick after a competing tab opens.beforeunloadhandler closes the channel cleanly but does not broadcast a "release" — inactive tabs stay inactive until the user explicitly clicks "Use here". This is intentional: simpler, and avoids auto-unblocking a tab the user isn't looking at.Phase 2: Block Keyboard Shortcuts When Inactive
Goal: Prevent keyboard shortcuts from firing in an inactive tab (the overlay blocks mouse events but not keyboard).
gtfsEditorinterface insrc/modules/keyboard-shortcuts.tsto include:keydownhandler inbindEventListeners()(keyboard-shortcuts.ts:218), add: This goes before theconst key = ...line, so inactive tabs silently swallow all shortcuts.Gotchas:
gtfsEditorparam is duck-typed (not an actualGTFSEditorimport), so adding an optional field is a safe, non-breaking change.pointer-eventscoverage prevents the user from ever focusing those elements in the first place.Original Issue
Likely we can only have one open session, so we'd have an in memory lock and you'd say "use here" if you want to switch or something.