diff --git a/src/zen/common/ZenUIManager.mjs b/src/zen/common/ZenUIManager.mjs index 7033ade80..fd0f8a896 100644 --- a/src/zen/common/ZenUIManager.mjs +++ b/src/zen/common/ZenUIManager.mjs @@ -175,25 +175,136 @@ var gZenUIManager = { _clearTimeout: null, _lastTab: null, + // Track tab switching state to prevent race conditions + _tabSwitchState: { + inProgress: false, + lastSwitchTime: 0, + debounceTime: 100, // ms to wait between tab switches + queue: [], + processingQueue: false, + }, + + // Queue tab switch operations to prevent race conditions + async _queueTabOperation(operation) { + // Add operation to queue + this._tabSwitchState.queue.push(operation); + + // If already processing queue, just return + if (this._tabSwitchState.processingQueue) { + return; + } + + // Start processing queue + this._tabSwitchState.processingQueue = true; + + try { + while (this._tabSwitchState.queue.length > 0) { + // Get next operation + const nextOp = this._tabSwitchState.queue.shift(); + + // Check if we need to wait for debounce + const now = Date.now(); + const timeSinceLastSwitch = now - this._tabSwitchState.lastSwitchTime; + + if (timeSinceLastSwitch < this._tabSwitchState.debounceTime) { + await new Promise((resolve) => setTimeout(resolve, this._tabSwitchState.debounceTime - timeSinceLastSwitch)); + } + + // Execute operation + this._tabSwitchState.inProgress = true; + await nextOp(); + this._tabSwitchState.inProgress = false; + this._tabSwitchState.lastSwitchTime = Date.now(); + } + } finally { + this._tabSwitchState.processingQueue = false; + } + }, + + // Check if browser elements are in a valid state for tab operations + _validateBrowserState() { + // Check if browser window is still open + if (window.closed) { + return false; + } + + // Check if gBrowser is available + if (!gBrowser || !gBrowser.tabContainer) { + return false; + } + + // Check if URL bar is available + if (!gURLBar) { + return false; + } + + return true; + }, + handleNewTab(werePassedURL, searchClipboard, where) { + // Validate browser state first + if (!this._validateBrowserState()) { + console.warn('Browser state invalid for new tab operation'); + return false; + } + const shouldOpenURLBar = gZenVerticalTabsManager._canReplaceNewTab && !werePassedURL && !searchClipboard && where === 'tab'; - if (shouldOpenURLBar) { + + if (!shouldOpenURLBar) { + return false; + } + + // Queue the tab operation to prevent race conditions + this._queueTabOperation(async () => { + // Clear any existing timeout if (this._clearTimeout) { clearTimeout(this._clearTimeout); + this._clearTimeout = null; } + + // Store the current tab this._lastTab = gBrowser.selectedTab; - this._lastTab._visuallySelected = false; - this._prevUrlbarLabel = gURLBar._untrimmedValue; + if (!this._lastTab) { + console.warn('No selected tab found when creating new tab'); + return false; + } + + // Set visual state with proper validation + if (this._lastTab && !this._lastTab.closing) { + this._lastTab._visuallySelected = false; + } + + // Store URL bar state + this._prevUrlbarLabel = gURLBar._untrimmedValue || ''; + + // Set up URL bar for new tab gURLBar._zenHandleUrlbarClose = this.handleUrlbarClose.bind(this); gURLBar.setAttribute('zen-newtab', true); + + // Update newtab buttons for (const button of this.newtabButtons) { button.setAttribute('in-urlbar', true); } - document.getElementById('Browser:OpenLocation').doCommand(); - gURLBar.search(this._lastSearch); - return true; - } - return false; + + // Open location command + try { + // Wait for a small delay to ensure DOM is ready + await new Promise((resolve) => setTimeout(resolve, 10)); + + document.getElementById('Browser:OpenLocation').doCommand(); + + // Wait for URL bar to be ready + await new Promise((resolve) => setTimeout(resolve, 10)); + + gURLBar.search(this._lastSearch || ''); + } catch (e) { + console.error('Error opening location in new tab:', e); + this.handleUrlbarClose(false); + return false; + } + }); + + return true; }, clearUrlbarData() { @@ -202,30 +313,67 @@ var gZenUIManager = { }, handleUrlbarClose(onSwitch) { - gURLBar._zenHandleUrlbarClose = null; - gURLBar.removeAttribute('zen-newtab'); - this._lastTab._visuallySelected = true; - this._lastTab = null; - for (const button of this.newtabButtons) { - button.removeAttribute('in-urlbar'); + // Validate browser state first + if (!this._validateBrowserState()) { + console.warn('Browser state invalid for URL bar close operation'); + return; } - if (onSwitch) { - this.clearUrlbarData(); - } else { - this._lastSearch = gURLBar._untrimmedValue; - this._clearTimeout = setTimeout(() => { - this.clearUrlbarData(); - }, this.urlbarWaitToClear); - } - gURLBar.setURI(this._prevUrlbarLabel, onSwitch, false, false, !onSwitch); - gURLBar.handleRevert(); - if (gURLBar.focused) { - gURLBar.view.close({ elementPicked: onSwitch }); - gURLBar.updateTextOverflow(); - if (gBrowser.selectedTab.linkedBrowser && onSwitch) { - gURLBar.getBrowserState(gBrowser.selectedTab.linkedBrowser).urlbarFocused = false; + + // Queue the operation to prevent race conditions + this._queueTabOperation(async () => { + // Reset URL bar state + if (gURLBar._zenHandleUrlbarClose) { + gURLBar._zenHandleUrlbarClose = null; } - } + gURLBar.removeAttribute('zen-newtab'); + + // Safely restore tab visual state with proper validation + if (this._lastTab && !this._lastTab.closing && this._lastTab.ownerGlobal && !this._lastTab.ownerGlobal.closed) { + this._lastTab._visuallySelected = true; + this._lastTab = null; + } + + // Reset newtab buttons + for (const button of this.newtabButtons) { + button.removeAttribute('in-urlbar'); + } + + // Handle search data + if (onSwitch) { + this.clearUrlbarData(); + } else { + this._lastSearch = gURLBar._untrimmedValue || ''; + + if (this._clearTimeout) { + clearTimeout(this._clearTimeout); + } + + this._clearTimeout = setTimeout(() => { + this.clearUrlbarData(); + }, this.urlbarWaitToClear); + } + + // Safely restore URL bar state with proper validation + if (this._prevUrlbarLabel) { + gURLBar.setURI(this._prevUrlbarLabel, onSwitch, false, false, !onSwitch); + } + + gURLBar.handleRevert(); + + if (gURLBar.focused) { + gURLBar.view.close({ elementPicked: onSwitch }); + gURLBar.updateTextOverflow(); + + // Ensure tab and browser are valid before updating state + const selectedTab = gBrowser.selectedTab; + if (selectedTab && selectedTab.linkedBrowser && !selectedTab.closing && onSwitch) { + const browserState = gURLBar.getBrowserState(selectedTab.linkedBrowser); + if (browserState) { + browserState.urlbarFocused = false; + } + } + } + }); }, urlbarTrim(aURL) { diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 24bd0b87f..e0a5e4598 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -50,6 +50,16 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { } async init() { + // Initialize tab selection state + this._tabSelectionState = { + inProgress: false, + lastSelectionTime: 0, + debounceTime: 100, // ms to wait between tab selections + }; + + // Initialize workspace change mutex + this._workspaceChangeInProgress = false; + if (!this.shouldHaveWorkspaces) { this._resolveInitialized(); document.getElementById('zen-current-workspace-indicator-container').setAttribute('hidden', 'true'); @@ -110,28 +120,130 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { ); } - selectEmptyTab(newTabTarget = null, selectURLBar = true) { - if (this._emptyTab && gZenVerticalTabsManager._canReplaceNewTab) { - if (gBrowser.selectedTab !== this._emptyTab && selectURLBar) { - window.addEventListener( - 'TabSelect', - () => { - setTimeout(() => { - gURLBar.select(); - }, 0); - }, - { once: true } - ); + // Validate browser state before tab operations + _validateBrowserState() { + // Check if browser window is still open + if (window.closed) { + return false; + } + + // Check if gBrowser is available + if (!gBrowser || !gBrowser.tabContainer) { + return false; + } + + // Check if URL bar is available + if (!gURLBar) { + return false; + } + + return true; + } + + // Safely select a tab with debouncing to prevent race conditions + async _safelySelectTab(tab) { + if (!tab || tab.closing || !tab.ownerGlobal || tab.ownerGlobal.closed) { + return false; + } + + // Check if we need to debounce + const now = Date.now(); + const timeSinceLastSelection = now - this._tabSelectionState.lastSelectionTime; + + if (timeSinceLastSelection < this._tabSelectionState.debounceTime) { + await new Promise((resolve) => setTimeout(resolve, this._tabSelectionState.debounceTime - timeSinceLastSelection)); + } + + // Mark selection as in progress + this._tabSelectionState.inProgress = true; + + try { + gBrowser.selectedTab = tab; + this._tabSelectionState.lastSelectionTime = Date.now(); + return true; + } catch (e) { + console.error('Error selecting tab:', e); + return false; + } finally { + this._tabSelectionState.inProgress = false; + } + } + + async selectEmptyTab(newTabTarget = null, selectURLBar = true) { + // Validate browser state first + if (!this._validateBrowserState()) { + console.warn('Browser state invalid for empty tab selection'); + return null; + } + + try { + // Check if we have a valid empty tab and can replace new tab + if ( + this._emptyTab && + !this._emptyTab.closing && + this._emptyTab.ownerGlobal && + !this._emptyTab.ownerGlobal.closed && + gZenVerticalTabsManager._canReplaceNewTab + ) { + // Only set up URL bar selection if we're switching to a different tab + if (gBrowser.selectedTab !== this._emptyTab && selectURLBar) { + // Use a Promise-based approach for better sequencing + const urlBarSelectionPromise = new Promise((resolve) => { + const tabSelectListener = () => { + // Remove the event listener first to prevent any chance of multiple executions + window.removeEventListener('TabSelect', tabSelectListener); + + // Use requestAnimationFrame to ensure DOM is updated + requestAnimationFrame(() => { + // Then use setTimeout to ensure browser has time to process tab switch + setTimeout(() => { + if (gURLBar) { + try { + gURLBar.select(); + } catch (e) { + console.warn('Error selecting URL bar:', e); + } + } + resolve(); + }, 50); + }); + }; + + window.addEventListener('TabSelect', tabSelectListener, { once: true }); + }); + } + + // Safely switch to the empty tab using our debounced method + const success = await this._safelySelectTab(this._emptyTab); + if (!success) { + throw new Error('Failed to select empty tab'); + } + + return this._emptyTab; } - gBrowser.selectedTab = this._emptyTab; - return this._emptyTab; + + // Fall back to creating a new tab + const newTabUrl = newTabTarget || Services.prefs.getStringPref('browser.startup.homepage'); + let tab = gZenUIManager.openAndChangeToTab(newTabUrl); + + // Set workspace ID if available + if (window.uuid) { + tab.setAttribute('zen-workspace-id', this.activeWorkspace); + } + return tab; + } catch (e) { + console.error('Error in selectEmptyTab:', e); + + // Create a fallback tab as a last resort, with proper validation + try { + if (this._validateBrowserState()) { + return gBrowser.addTrustedTab('about:blank'); + } + } catch (fallbackError) { + console.error('Critical error creating fallback tab:', fallbackError); + } + return null; } - const newTabUrl = newTabTarget || Services.prefs.getStringPref('browser.startup.homepage'); - let tab = gZenUIManager.openAndChangeToTab(newTabUrl); - if (window.uuid) { - tab.setAttribute('zen-workspace-id', this.activeWorkspace); - } - return tab; } async delayedStartup() { @@ -2429,10 +2541,45 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { } async switchTabIfNeeded(tab) { - if (!tab.hasAttribute('zen-essential') && tab.getAttribute('zen-workspace-id') !== this.activeWorkspace) { - await this.changeWorkspace({ uuid: tab.getAttribute('zen-workspace-id') }); + // Validate browser state first + if (!this._validateBrowserState()) { + console.warn('Browser state invalid for tab switching'); + return; + } + + if (!tab) { + console.warn('switchTabIfNeeded called with null tab'); + return; + } + + // Validate tab state + if (tab.closing || !tab.ownerGlobal || tab.ownerGlobal.closed || !tab.linkedBrowser) { + console.warn('Tab is no longer valid, cannot select it'); + return; + } + + try { + // Check if we need to change workspace + if (!tab.hasAttribute('zen-essential') && tab.getAttribute('zen-workspace-id') !== this.activeWorkspace) { + // Use a mutex-like approach to prevent concurrent workspace changes + if (this._workspaceChangeInProgress) { + console.warn('Workspace change already in progress, deferring tab switch'); + return; + } + + this._workspaceChangeInProgress = true; + try { + await this.changeWorkspace({ uuid: tab.getAttribute('zen-workspace-id') }); + } finally { + this._workspaceChangeInProgress = false; + } + } + + // Safely switch to the tab using our debounced method + await this._safelySelectTab(tab); + } catch (e) { + console.error('Error in switchTabIfNeeded:', e); } - gBrowser.selectedTab = tab; } getDefaultContainer() {