fix: Ensure non-destroyed window exists for authentication event handler - #86
Conversation
988b450 to
44078d9
Compare
Providing the cached main window reference at the time of event listener creation could result in the callback attempting to emit events on a window that had since been destroyed.
Currying is unnecessary as (1) we have no use for it currently and (2) this higher-order function will most likely always want to be invoked immediately to ensure the window reference provided is current and available.
This same logic is run in an event handler for the window `close` event, which makes this invocation unnecessary.
Registering the `open-url` event handler _before_ the app `ready` event, which is explicitly directed in the Electron, results in the URL handler correctly manages authentication on app start. https://www.electronjs.org/docs/latest/api/app#event-open-url-macos
The final scenario of `did-finish-load` did not return the actual window, so never returning the window is more consistent.
Use "set up" verb rather than "setup" noun.
There appears to be no reason to await the app `ready` event.
Intended to await the mock promise, not the mock function.
f00af0a to
3361577
Compare
The CI server does not leverage the `darwin` platform, so we must mock the platform to verify the deep link handler.
31af4e0 to
13db766
Compare
| MAIN_WINDOW_WEBPACK_ENTRY: 'main-window-webpack-entry', | ||
| MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: 'main-window-preload-webpack-entry', |
There was a problem hiding this comment.
These additions mirror globals assigned by electron-forge's webpack build configuration.
|
|
||
| Menu.setApplicationMenu( null ); | ||
|
|
||
| setupCustomProtocolHandler(); |
There was a problem hiding this comment.
This was relocated out of the app ready event callback as it registers an event handler for the open-url event, which Electron's documentation explicitly directs not to register after the ready event.
|
|
||
| mainWindow = createMainWindow(); | ||
| setupAuthCallbackHandler( mainWindow ); | ||
| handleAuthOnStartup(); |
There was a problem hiding this comment.
This manual authentication handling become unnecessary now that the open-url event handler is registered before the app ready event.
| if ( mainWindow && ! mainWindow.isDestroyed() ) { | ||
| return mainWindow; | ||
| } |
There was a problem hiding this comment.
This guard prevents reassigning mainWindow when it is already populated with a non-destroyed window to avoid creating lingering windows. If that is confusing for a method named createMainWindow, we might instead destroy the existing window.
| /** | ||
| * Reset the main window reference. Exported for testing as resetting modules | ||
| * with Jest while preserving manual Electron mocks proved quite difficult. | ||
| */ | ||
| export function __resetMainWindow() { | ||
| mainWindow = null; | ||
| } |
There was a problem hiding this comment.
This internal, testing-only function is less than ideal, but I tried a few different approaches (e.g, jest.doMock, jest.resetModules, jest.isolatedModules) all of which resulted in a shortcoming — mocking too late or breaking other manual package mocks.
|
|
||
| setupCustomProtocolHandler(); | ||
|
|
||
| setUpAuthCallbackHandler(); |
There was a problem hiding this comment.
This was relocated out of the app ready event callback as there is no longer a reason to await that event now that we do not reference mainWindow until within the event callback.
| ( fs as MockedFs ).__setFileContents( '/path/to/app/temp/com.wordpress.studio/', '' ); | ||
|
|
||
| it( 'should boot successfully', () => { | ||
| jest.isolateModules( () => { |
There was a problem hiding this comment.
The entry module includes side effects, so we must leverage isolateModules to reset/clear those side effects for each test case.
wojtekn
left a comment
There was a problem hiding this comment.
All cases work correctly. I didn't spot any regression issues.
Suspect IssuesThis pull request was deployed and Sentry observed the following issues:
Did you find this useful? React with a 👍 or 👎 |
## Related issues - Fixes [STU-1750](https://linear.app/a8c/issue/STU-1750/studio-reopens-multiple-times-when-quitting-during-site-startup) ## How AI was used in this PR Claude Code investigated the bug, identified the root cause (create-if-missing branch in `getMainWindow()` racing with async IPC fan-out during the `will-quit` shutdown window), proposed the minimal gate at `sendIpcEventToRenderer`, and applied the change. I reviewed the diff and confirmed both the fix and its placement before committing. ## Proposed Changes - Add an `isAppQuitting` flag in `apps/studio/src/ipc-utils.ts` with an exported `markAppQuitting()` setter. While set, `sendIpcEventToRenderer` returns early without calling `getMainWindow()`. - Call `markAppQuitting()` at the start of the `will-quit` handler in `apps/studio/src/index.ts`, before any other cleanup. ### Why this works When the user confirms "Stop sites" on quit, `will-quit` calls `event.preventDefault()` and waits up to 6 s for `stopAllServers()` to drain. During that window, in-flight async callbacks (site-startup events, snapshot events, sync progress, etc.) keep firing through `sendIpcEventToRenderer()`. Each call routes through `getMainWindow()` — and because the original window has typically already been closed by Electron's shutdown sequence, `getMainWindow()` falls through to `createMainWindow()` and spawns a fresh `BrowserWindow`. One pending event = one ghost window. With ~6 sites starting simultaneously on a Linux VM, you reliably see ~6 windows reopen. Blocking the fan-out at the `sendIpcEventToRenderer` chokepoint is the smallest fix: it catches every async-IPC path without changing `getMainWindow`'s signature or touching its 15+ other call sites (menus, deeplinks, update dialog, etc.), which are user-triggered and can't fire post-`will-quit` anyway. `will-quit` (not `before-quit`) is the right gate point: `before-quit` can still be cancelled by the sync-in-progress dialog or the "Cancel" button on the running-sites dialog. Once `will-quit` fires, the app is committed. ### Regression origin The mechanic was introduced in #86 (May 2024) for OAuth deep-link handling. It became today's user-visible bug in #849 (Jan 2025), which routed `sendIpcEventToRenderer` through `getMainWindow()`. The bug got more reproducible recently due to Linux VM testing (slower shutdown widens the race window) plus PR #3350 removing an accidental escape hatch that bypassed the quit dialog when the CLI wasn't installed. ## Testing Instructions Reproduce on `trunk` first, then verify the fix on this branch. **Repro on trunk (Linux/Windows easier; macOS rarer):** 1. Have several Studio sites created (~6 makes it obvious). 2. Quit Studio so all sites stop. 3. Re-launch Studio. 4. Start all sites at once. 5. **Before** they finish booting, quit Studio. 6. Observe: Studio reopens 4–6 times in quick succession. **Verify on this branch:** 1. Repeat steps 1–5 above. 2. Studio should quit cleanly with no ghost windows. ## Pre-merge Checklist - [x] Have you checked for TypeScript, React or other console errors? - [x] Manual repro on Linux (with several sites) - [x] Manual repro on Windows (with several sites)
Fixes https://github.com/Automattic/dotcom-forge/issues/6906.
Fixes #14.
Proposed Changes
Replace a cached main window reference with a more robust implementation of the
pre-existing
withMainWindowutility used by the menu creation logic. The newapproach ensures a non-destroyed window is available whenever an event handler
attempts to reference it by accessing or creating the window within the event
handler, rather than using a main window reference that was passed when the
event handler was registered.
Testing Instructions
1. Authentication flow succeeds after closing and re-opening Studio's window
2. Authentication flow succeeds after quitting Studio
Important
This test case requires uninstalling all other Studio app installs before testing and using a build from
npm run make.3. Studio menu items have not regressed
Verify the various menu items —
Add Site...,Settings...,Close Window, etc — continue to function as expected both with and without an active Studio window open.Pre-merge Checklist